This commit is contained in:
ponzischeme89
2026-08-11 23:41:10 +12:00
parent 0e183cf560
commit 5fed3360c2
42 changed files with 1340 additions and 130 deletions
+17
View File
@@ -1,3 +1,20 @@
## 0.2.53 — 2026-08-11
- Improved: The play button used by the mini hero cards now appears over every focused poster.
- Improved: Reduced the primary hero logo so its supporting details remain visible.
- Improved: The profile rail now shows the name of the user signed in on the television.
- Improved: Renamed My Alerts to Notifications.
- Improved: Added notifications for every user when Sonarr reports that any show has been cancelled.
- Improved: Added a daily Sonarr lifecycle scan and status history so newly cancelled shows can be identified.
## 0.2.52 — 2026-08-11
- Fixed: The last refreshed date in the Devices list used the incorrect date format.
- Fixed: The show or film logo was missing from the primary home-page hero.
- Fixed: My Alerts did not include an alert when Memby had been updated.
- Fixed: The release date in version history used the incorrect date format.
- Improved: Added soundbar and passthrough settings to the on-screen display.
- Improved: Slimmed down the subtitles menu and made the Get subtitles button easier to see.
- Improved: Added country flags to the subtitles search and menu.
## 0.2.51 — 2026-08-11
- Improved: For You now begins loading after Home arrives, so its personalised rows are usually ready before you open it.
- Fixed: Frequent screen updates can no longer repeat network requests, navigation, analytics, timers or other background work.
+9 -1
View File
@@ -427,7 +427,7 @@ the alert that announced it has long since fallen out of its window. Things to p
— unauthenticated on purpose, since a probe needing a token would report a stale session
as a server outage.
**My Alerts belongs to a person, so it lives in the user picker.** A service alert is the
**Notifications belong to a person, so they live in the user picker.** A service alert is the
house being told something; these are one viewer's own news (a followed show returning),
stored per user on the gateway and following them to whichever television they sign into.
`ui/alerts/AlertsPage.kt` is the full page and the user menu in `UserSwitcherOverlay` is the
@@ -450,6 +450,14 @@ behind it. Things to preserve:
- **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns
the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`.
- **A cancellation is a transition, not a status read in isolation.**
`sonarr_series_status_history` stores the first daily Sonarr reading as a quiet baseline
and appends only changes after it; `WatchSonarrLifecycle` creates a notification for every
known user when any show moves from continuing/upcoming to ended/deleted. Cancellation
news is household-wide and does not depend on whether a viewer followed the show. Without
the baseline,
enabling the scanner would announce every show that had already ended as new news, and
without durable history a gateway restart could announce the same change again.
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.51"
val defaultVersionName = "0.2.53"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -403,6 +403,10 @@ data class Settings(
* not seen that build's update notice.
*/
val whatsNewSeenVersion: String? = null,
/** The most recent app-update alert retained for this television's Notifications page. */
val updateAlertVersion: String? = null,
val updateAlertAt: String? = null,
val updateAlertRead: Boolean = false,
/**
* The version the gateway last refused this build over, or null while it has said
* nothing. Device state, and deliberately outlives both the session and the process:
@@ -565,6 +569,9 @@ class SettingsStore(private val context: Context) {
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
val UPDATE_ALERT_VERSION = stringPreferencesKey("update_alert_version")
val UPDATE_ALERT_AT = stringPreferencesKey("update_alert_at")
val UPDATE_ALERT_READ = booleanPreferencesKey("update_alert_read")
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
}
@@ -1043,11 +1050,32 @@ class SettingsStore(private val context: Context) {
* once. Also written silently on a fresh install, which has not updated from an earlier
* build and therefore has nothing to announce.
*/
suspend fun markWhatsNewSeen(version: String) {
suspend fun markWhatsNewSeen(version: String, updateAlertAt: String? = null) {
val trimmed = version.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
preferences[Keys.WHATS_NEW_VERSION] = trimmed
updateAlertAt?.trim()?.takeIf(String::isNotEmpty)?.let { occurredAt ->
preferences[Keys.UPDATE_ALERT_VERSION] = trimmed
preferences[Keys.UPDATE_ALERT_AT] = occurredAt
preferences[Keys.UPDATE_ALERT_READ] = false
}
}
}
suspend fun markUpdateAlertRead() {
context.dataStore.edit { preferences ->
if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) {
preferences[Keys.UPDATE_ALERT_READ] = true
}
}
}
suspend fun dismissUpdateAlert() {
context.dataStore.edit { preferences ->
preferences.remove(Keys.UPDATE_ALERT_VERSION)
preferences.remove(Keys.UPDATE_ALERT_AT)
preferences.remove(Keys.UPDATE_ALERT_READ)
}
}
@@ -1481,6 +1509,9 @@ class SettingsStore(private val context: Context) {
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
updateAlertVersion = preferences[Keys.UPDATE_ALERT_VERSION],
updateAlertAt = preferences[Keys.UPDATE_ALERT_AT],
updateAlertRead = preferences[Keys.UPDATE_ALERT_READ] ?: false,
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
profiles = profiles,
@@ -115,6 +115,53 @@ fun normalizeSubtitleLanguage(raw: String?): String {
return subtitleLanguageAliases[base] ?: base
}
/** A familiar country marker for a subtitle language, or empty for an unknown code. */
fun subtitleCountryFlag(raw: String?): String {
val tagged = raw?.trim()?.lowercase()?.replace('_', '-').orEmpty()
when {
tagged.startsWith("en-us") -> return "🇺🇸"
tagged.startsWith("en-au") -> return "🇦🇺"
tagged.startsWith("en-nz") -> return "🇳🇿"
tagged.startsWith("pt-br") -> return "🇧🇷"
tagged.startsWith("es-mx") -> return "🇲🇽"
tagged.startsWith("zh-tw") -> return "🇹🇼"
tagged.startsWith("zh-hk") || tagged.startsWith("yue") -> return "🇭🇰"
}
return when (normalizeSubtitleLanguage(raw)) {
"en" -> "🇬🇧"
"it" -> "🇮🇹"
"es" -> "🇪🇸"
"fr" -> "🇫🇷"
"de" -> "🇩🇪"
"pt" -> "🇵🇹"
"nl" -> "🇳🇱"
"sv" -> "🇸🇪"
"da" -> "🇩🇰"
"no" -> "🇳🇴"
"fi" -> "🇫🇮"
"pl" -> "🇵🇱"
"cs" -> "🇨🇿"
"hu" -> "🇭🇺"
"ro" -> "🇷🇴"
"el" -> "🇬🇷"
"ru" -> "🇷🇺"
"uk" -> "🇺🇦"
"tr" -> "🇹🇷"
"ar" -> "🇸🇦"
"he" -> "🇮🇱"
"hi" -> "🇮🇳"
"ja" -> "🇯🇵"
"ko" -> "🇰🇷"
"zh" -> "🇨🇳"
"th" -> "🇹🇭"
"vi" -> "🇻🇳"
else -> ""
}
}
fun subtitleLabelWithFlag(language: String?, label: String): String =
subtitleCountryFlag(language).let { flag -> if (flag.isBlank()) label else "$flag $label" }
/** The parts of a subtitle track [selectSubtitleId] needs, whatever produced it. */
data class SubtitleCandidate(
val id: String,
@@ -173,7 +173,7 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
// has one — see [TvNavigationRail]'s calendarEnabled.
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
FAVORITES("Favourites", Icons.Default.Favorite),
PROFILES("Switch user", Icons.Default.Person),
PROFILES("User", Icons.Default.Person),
SETTINGS("Settings", Icons.Default.Settings),
}
@@ -361,6 +361,11 @@ fun TvNavigationRail(
destinations.forEach { destination ->
ExpandableNavigationItem(
destination = destination,
label = if (destination == BrowseDestination.PROFILES) {
profileDestinationLabel(activeUsername)
} else {
destination.label
},
selected = destination == selected,
expanded = expanded,
modifier = if (destination == focusDestination) {
@@ -370,7 +375,7 @@ fun TvNavigationRail(
},
onFocused = {},
onClick = { onDestinationSelected(destination) },
// My Alerts is one level in, behind the user picker. Without a mark out
// Notifications are one level in, behind the user picker. Without a mark out
// here nothing on the launcher would ever say there was news waiting.
badge = if (destination == BrowseDestination.PROFILES) {
alertBadgeLabel(alertCount)
@@ -526,12 +531,12 @@ fun UserSwitcherOverlay(
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
// My Alerts lives here rather than on the launcher: these alerts belong to a
// Notifications live here rather than on the launcher: these belong to a
// person and follow them between televisions, so the menu that already answers
// "who is watching" is where somebody looks for their own news. The badge is
// what replaces the bell that used to sit in the corner of Home.
UserSwitcherAction(
label = "My Alerts",
label = "Notifications",
icon = Icons.Default.Notifications,
badge = alertBadgeLabel(alertCount),
modifier = Modifier
@@ -555,7 +560,7 @@ fun UserSwitcherOverlay(
}
}
/** My Alerts, then Manage users. See [userSwitcherNextIndex]. */
/** Notifications, then Manage users. See [userSwitcherNextIndex]. */
private const val UserSwitcherActionCount = 2
@Composable
@@ -678,6 +683,7 @@ private val AlertBadgeRed = Color(0xFFE04747)
@Composable
fun ExpandableNavigationItem(
destination: BrowseDestination,
label: String = destination.label,
selected: Boolean,
expanded: Boolean,
onFocused: () -> Unit,
@@ -719,8 +725,7 @@ fun ExpandableNavigationItem(
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics {
contentDescription = badge?.let { "${destination.label}, $it waiting" }
?: destination.label
contentDescription = badge?.let { "$label, $it waiting" } ?: label
}
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -773,7 +778,7 @@ fun ExpandableNavigationItem(
}
if (expanded) {
Text(
destination.label,
label,
color = foreground.value,
fontSize = 15.sp,
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
@@ -799,6 +804,10 @@ internal fun profileInitials(username: String): String {
}
}
/** The profile rail names the person currently signed in, not the action behind the row. */
internal fun profileDestinationLabel(username: String): String =
username.trim().ifEmpty { "User" }
@Composable
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
val context = LocalContext.current
@@ -2143,6 +2152,9 @@ private fun MediaCard(
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
if (focused) {
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
}
}
Text(
item.name,
@@ -2,7 +2,6 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image
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
@@ -16,10 +15,7 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,7 +26,6 @@ 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.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
@@ -43,7 +38,6 @@ 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.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
@@ -345,6 +339,12 @@ private fun FeaturedMovieCard(
previewArtwork: ImageBitmap?,
) {
val item = pick.item
val repository = ServiceLocator.repository
val showLogo = repository.showTitleLogo
val logoUrl = remember(item.id, item.imageTags, item.parentLogoItemId, item.parentLogoImageTag, showLogo) {
if (showLogo) repository.logoUrl(item, 640) else null
}
val useTextTitle = useTextTitleForLogo(logoUrl)
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
@@ -403,16 +403,26 @@ private fun FeaturedMovieCard(
// on the least specific thing on it — and it is the line that pushed a
// wrapped title into the button. The three minis beside it keep theirs:
// they have no fact line, and there the label is the only reason given.
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLines = it.lineCount },
)
if (useTextTitle) {
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLines = it.lineCount },
)
} else {
AsyncImage(
model = logoUrl,
contentDescription = item.name,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.width(240.dp).height(56.dp),
)
}
Spacer(Modifier.height(9.dp))
HeroFactLine(item)
// The reason takes the synopsis's place rather than adding a line to
@@ -540,24 +550,11 @@ private fun MiniMovieCard(
}
}
if (focused) {
Box(
MembyArtworkPlayCue(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
.size(38.dp)
.shadow(14.dp, CircleShape)
.clip(CircleShape)
.background(MembyAccent)
.border(2.dp, Color.White, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
.padding(end = 16.dp),
)
}
}
}
@@ -179,6 +179,26 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Date
import java.util.Calendar
import java.time.Instant
internal const val MEMBY_UPDATE_NOTIFICATION_ID = -1L
internal fun membyUpdateNotification(
version: String?,
occurredAt: String?,
read: Boolean,
): UserNotification? {
val installed = version?.trim()?.takeIf(String::isNotEmpty) ?: return null
return UserNotification(
id = MEMBY_UPDATE_NOTIFICATION_ID,
kind = "app-update",
title = "Memby updated",
message = "This TV is now running Memby $installed.",
eventAt = occurredAt,
createdAt = occurredAt.orEmpty(),
readAt = if (read) occurredAt ?: "read" else null,
)
}
/**
* The followed show a press stands for, as much of it as the card already knows.
@@ -498,7 +518,10 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// Persist the receipt before producing the external effect. If Android
// recreates the activity immediately after the toast, the new composition
// must not announce the same build a second time.
ServiceLocator.settings.markWhatsNewSeen(decision.version)
ServiceLocator.settings.markWhatsNewSeen(
decision.version,
updateAlertAt = Instant.now().toString(),
)
currentCoroutineContext().ensureActive()
Toast.makeText(
context,
@@ -1938,6 +1961,13 @@ private fun HomeScreen(
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
val displayedNotifications = listOfNotNull(
membyUpdateNotification(
settings.updateAlertVersion,
settings.updateAlertAt,
settings.updateAlertRead,
),
) + notificationState.notifications
var showNotifications by remember { mutableStateOf(false) }
// Two different things: [launchingItem] is the gate that stops a second Play press
// stacking a second player, and stays shut until one comes back. [resolvingItem] is
@@ -2396,7 +2426,7 @@ private fun HomeScreen(
}
}
},
alertCount = notificationState.notifications.size,
alertCount = displayedNotifications.size,
activeUsername = settings.username.orEmpty(),
calendarEnabled = tvCalendarEnabled,
)
@@ -2408,7 +2438,7 @@ private fun HomeScreen(
) {
val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage
if (maintenanceMessage != null) {
// The rows are gone but the rail is not: Settings and Switch user are
// The rows are gone but the rail is not: Settings and the active user are
// local, so there is no reason to strand the viewer here.
MaintenanceScreen(
message = maintenanceMessage,
@@ -3037,7 +3067,7 @@ private fun HomeScreen(
navigationExpanded = false
showProfiles = true
},
alertCount = notificationState.notifications.size,
alertCount = displayedNotifications.size,
onOpenAlerts = {
homeViewModel.trackJourney(
category = "notifications", action = "open", screen = journeyScreen,
@@ -3086,7 +3116,7 @@ private fun HomeScreen(
}
BackHandler(onBack = closeSettings)
// Settings is a destination, not a takeover: it leaves the main rail visible
// and reachable so Home, Search and Switch user are one Left press away, the
// and reachable so Home, Search and the active user are one Left press away, the
// same as from every other page. The rail below is the live one — this Row
// only reserves its collapsed footprint and takes the same expand shift the
// content area does, so an expanded rail slides Settings aside rather than
@@ -3329,7 +3359,7 @@ private fun HomeScreen(
}
BackHandler(onBack = closeAlerts)
MyAlertsPage(
notifications = notificationState.notifications,
notifications = displayedNotifications,
preferences = notificationState.preferences,
loading = notificationsLoading,
errorMessage = notificationsError,
@@ -3373,7 +3403,11 @@ private fun HomeScreen(
// connection walking down the list and back up would send the same row's
// request once per pass — clearing the flag immediately is what makes the
// row stop asking.
onRead = { notification ->
onRead = onRead@{ notification ->
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.markUpdateAlertRead() }
return@onRead
}
val previousReadAt = notification.readAt
notificationState = notificationState.copy(
notifications = notificationState.notifications.map {
@@ -3401,8 +3435,12 @@ private fun HomeScreen(
// while its request was in flight is a row pressed again — which on this
// page also re-aims where focus lands afterwards. A failure puts the row
// back where it was rather than quietly losing somebody's alert.
onDismiss = { notification ->
if (notificationsMutationBusy) return@MyAlertsPage
onDismiss = onDismiss@{ notification ->
if (notificationsMutationBusy) return@onDismiss
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.dismissUpdateAlert() }
return@onDismiss
}
notificationsMutationBusy = true
val previous = notificationState
notificationState = notificationState.copy(
@@ -3428,8 +3466,10 @@ private fun HomeScreen(
notificationsMutationBusy = true
val previous = notificationState
val pending = notificationState.notifications.map(UserNotification::id)
val dismissLocalUpdate = settings.updateAlertVersion != null
notificationState = notificationState.copy(notifications = emptyList())
scope.launch {
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
val failed = pending.filter { id ->
runCatching { repo.dismissNotification(id) }.isFailure
}
@@ -77,7 +77,7 @@ private const val RETRY_SECONDS = 30
/**
* Fills the content area while the gateway reports a deliberate outage.
*
* The navigation rail deliberately stays mounted beside this: Settings and Switch user
* The navigation rail deliberately stays mounted beside this: Settings and the active user
* are local, so there is no reason to strand the viewer just because content is
* unavailable. Everything here is cheap to draw a handful of animated floats and plain
* Canvas geometry because TV GPUs punish blur and layered transparency.
@@ -7,10 +7,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PlayArrow
@@ -88,6 +90,32 @@ internal fun MembyPlayChip(
)
}
/**
* The circular Play treatment shown over a focused artwork card.
*
* The card itself remains the focusable and clickable node; this is only the visual cue,
* shared by the mini hero cards and the poster shelves so Play means one shape everywhere.
*/
@Composable
internal fun MembyArtworkPlayCue(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(38.dp)
.shadow(14.dp, CircleShape)
.clip(CircleShape)
.background(MembyAccent)
.border(2.dp, Color.White, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
@Composable
private fun PrimaryActionSurface(
label: String,
@@ -3,7 +3,7 @@ package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* Profiles occupy [0, profileCount); the pinned actions My Alerts, then Manage users
* Profiles occupy [0, profileCount); the pinned actions Notifications, then Manage users
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
* deterministic.
*/
@@ -1,7 +1,7 @@
package com.ponzischeme89.memby.ui.alerts
/**
* The wording and the counting behind My Alerts, kept pure so the badge a viewer sees in the
* The wording and the counting behind Notifications, kept pure so the badge a viewer sees in the
* user picker and the summary line on the page itself are the same arithmetic tested once.
*
* The badge answers "is there anything waiting for me", which is why it counts *alerts* and
@@ -26,9 +26,9 @@ internal fun alertBadgeLabel(count: Int): String? = when {
*/
internal fun alertsSummary(total: Int, unread: Int): String = when {
total <= 0 -> "Nothing waiting for you"
unread <= 0 -> if (total == 1) "1 alert" else "$total alerts"
unread <= 0 -> if (total == 1) "1 notification" else "$total notifications"
else -> {
val alerts = if (total == 1) "1 alert" else "$total alerts"
"$alerts · $unread new"
val notifications = if (total == 1) "1 notification" else "$total notifications"
"$notifications · $unread new"
}
}
@@ -60,7 +60,7 @@ import com.ponzischeme89.memby.ui.theme.MembySurface
import kotlinx.coroutines.delay
/**
* My Alerts a full page rather than the panel it used to be, reached from the user menu in
* Notifications a full page rather than the panel it used to be, reached from the user menu in
* the user picker.
*
* It moved there because these alerts belong to a *person*, not to a television: they follow
@@ -150,7 +150,7 @@ fun MyAlertsPage(
verticalAlignment = Alignment.CenterVertically,
) {
MembyChoiceChip(
label = if (preferences.enabled) "Alerts on" else "Alerts off",
label = if (preferences.enabled) "Notifications on" else "Notifications off",
selected = preferences.enabled,
onClick = onToggleEnabled,
modifier = Modifier.focusRequester(actionsFocusRequester),
@@ -177,7 +177,7 @@ fun MyAlertsPage(
Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (loading && !hasAlerts) {
AlertsNotice("Loading alerts…")
AlertsNotice("Loading notifications…")
} else if (errorMessage != null && !hasAlerts) {
AlertsNotice(errorMessage)
} else if (!hasAlerts) {
@@ -232,7 +232,7 @@ private fun AlertsHeader(total: Int, unread: Int) {
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("My Alerts", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
}
}
@@ -248,9 +248,9 @@ private fun AlertsEmptyState(enabled: Boolean) {
Spacer(Modifier.height(8.dp))
Text(
if (enabled) {
"Alerts about the shows you follow will show up here."
"Notifications about the shows you follow will show up here."
} else {
"Alerts are switched off, so nothing new will arrive here."
"Notifications are switched off, so nothing new will arrive here."
},
color = MembyQuietText,
fontSize = 14.sp,
@@ -356,5 +356,6 @@ private fun AlertRow(
private fun alertIcon(kind: String): ImageVector = when {
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
kind.contains("series", ignoreCase = true) -> Icons.Default.Tv
kind.startsWith("show-", ignoreCase = true) -> Icons.Default.Tv
else -> Icons.Default.NotificationsActive
}
@@ -76,12 +76,16 @@ import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.resolveCast
import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.subtitleLabelWithFlag
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.ui.MainActivity
import com.ponzischeme89.memby.ui.EmbyOutageBanner
import com.ponzischeme89.memby.ui.ServiceAlertBanner
@@ -104,6 +108,13 @@ import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): String = when {
preference.mode == AudioPassthroughMode.AUTO -> "Auto"
preference.codecs.isEmpty() -> "Off"
preference.codecs.size == 1 -> preference.codecs.single().label
else -> "${preference.codecs.size} formats"
}
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button
@@ -3194,10 +3205,14 @@ class PlayerActivity : ComponentActivity() {
}
// Cast is deliberately absent: it has its own button in the transport row now, and
// one thing reachable two ways is one thing whose two entry points drift apart.
val passthrough = ServiceLocator.settings.current.audioPassthroughPreference
val passthroughOption = "Soundbar / passthrough · ${passthroughOsdSummary(passthrough)}"
val pictureOption = "Picture size · ${selectedPictureMode().shortLabel}"
val options = buildList {
if (audio) add("Audio")
add("Subtitles & appearance")
add("Picture size · ${selectedPictureMode().shortLabel}")
add(passthroughOption)
add(pictureOption)
}
AlertDialog.Builder(this)
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
@@ -3205,12 +3220,67 @@ class PlayerActivity : ComponentActivity() {
when (options[which]) {
"Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
"Subtitles & appearance" -> showSubtitleOverlay()
else -> showPictureModePicker()
passthroughOption -> showAudioPassthroughPicker()
pictureOption -> showPictureModePicker()
}
}
.show()
}
private fun showAudioPassthroughPicker() {
val current = ServiceLocator.settings.current.audioPassthroughPreference
val options = arrayOf("Auto detect", "Off", "Choose formats…")
val selected = when {
current.mode == AudioPassthroughMode.AUTO -> 0
current.codecs.isEmpty() -> 1
else -> 2
}
AlertDialog.Builder(this)
.setTitle("Soundbar / passthrough")
.setSingleChoiceItems(options, selected) { dialog, which ->
when (which) {
0 -> saveAudioPassthrough(AudioPassthroughMode.AUTO, current.codecs)
1 -> saveAudioPassthrough(AudioPassthroughMode.MANUAL, emptySet())
else -> {
dialog.dismiss()
showManualPassthroughPicker(current.codecs)
return@setSingleChoiceItems
}
}
dialog.dismiss()
}
.setNegativeButton("Cancel", null)
.show()
}
private fun showManualPassthroughPicker(initial: Set<SurroundCodec>) {
val selected = SurroundCodec.entries.map { it in initial }.toBooleanArray()
AlertDialog.Builder(this)
.setTitle("Formats accepted by your soundbar")
.setMultiChoiceItems(
SurroundCodec.entries.map(SurroundCodec::label).toTypedArray(),
selected,
) { _, which, checked -> selected[which] = checked }
.setPositiveButton("Save") { _, _ ->
saveAudioPassthrough(
AudioPassthroughMode.MANUAL,
SurroundCodec.entries.filterIndexed { index, _ -> selected[index] }.toSet(),
)
}
.setNegativeButton("Cancel", null)
.show()
}
private fun saveAudioPassthrough(mode: AudioPassthroughMode, codecs: Set<SurroundCodec>) {
lifecycleScope.launch { ServiceLocator.settings.setAudioPassthrough(mode, codecs) }
Toast.makeText(
this,
"Soundbar setting saved. It applies when the next video starts.",
Toast.LENGTH_LONG,
).show()
playerView?.hideController()
}
private fun showPictureModePicker() {
val selected = selectedPictureMode()
AlertDialog.Builder(this)
@@ -3483,7 +3553,7 @@ class PlayerActivity : ComponentActivity() {
discoveredTracks += TrackChoice(
group.mediaTrackGroup,
index,
trackLabel(group.mediaTrackGroup, index),
trackLabel(group.mediaTrackGroup, index, showSubtitleFlag = true),
)
}
}
@@ -3609,7 +3679,10 @@ class PlayerActivity : ComponentActivity() {
subtitleCandidates.forEach { candidate ->
add(
SubtitleMenuEntry(
label = candidate.label.ifBlank { candidate.languageLabel },
label = subtitleLabelWithFlag(
candidate.language,
candidate.label.ifBlank { candidate.languageLabel },
),
selected = false,
enabled = !subtitleRequestInFlight,
),
@@ -3817,7 +3890,8 @@ class PlayerActivity : ComponentActivity() {
if (subtitle.isDefault) add("Default")
if (subtitle.isHearingImpaired) add("SDH")
}
return if (flags.isEmpty()) name else "$name · ${flags.joinToString(" · ")}"
val label = if (flags.isEmpty()) name else "$name · ${flags.joinToString(" · ")}"
return subtitleLabelWithFlag(subtitle.language, label)
}
private fun selectEncodedSubtitle(subtitle: PlayableSubtitle) {
@@ -3933,7 +4007,7 @@ class PlayerActivity : ComponentActivity() {
}
@OptIn(UnstableApi::class)
private fun trackLabel(group: TrackGroup, index: Int): String {
private fun trackLabel(group: TrackGroup, index: Int, showSubtitleFlag: Boolean = false): String {
val format = group.getFormat(index)
val rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() }
val meaningfulLabel = rawLabel?.takeUnless { it.matches(Regex("""^\([^)]*\)$""")) }
@@ -3948,7 +4022,8 @@ class PlayerActivity : ComponentActivity() {
if (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0) add("Default")
if (format.roleFlags and C.ROLE_FLAG_CAPTION != 0) add("CC")
}
return if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}"
val label = if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}"
return if (showSubtitleFlag) subtitleLabelWithFlag(format.language, label) else label
}
override fun onNewIntent(intent: Intent) {
@@ -7,6 +7,7 @@ import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
@@ -39,7 +40,7 @@ data class SubtitleDownloadState(
val entries: List<SubtitleMenuEntry> = emptyList(),
/**
* Whether the panel has switched to being *about* downloading, which hides the track
* list and the text-size chips. It is not decoration: the drop-up is 344dp wide by
* list and the text-size chips. It is not decoration: the drop-up is 316dp wide by
* about a third of a 720p screen, and holding a track list, a size row and a list of
* search results at once squeezed the tracks down to a single visible row. One question
* at a time, and Back is what steps out of this one.
@@ -99,6 +100,9 @@ fun bindSubtitleMenu(
tracks.forEachIndexed { index, entry ->
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
}
overlay.findViewById<ScrollView>(R.id.player_subtitle_tracks_scroll)?.apply {
layoutParams = layoutParams.apply { height = menuRowsHeight(context, tracks.size, 3) }
}
sizes.forEachIndexed { index, entry ->
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
}
@@ -128,6 +132,16 @@ private fun bindDownloadSection(
downloads.entries.forEachIndexed { index, entry ->
container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) })
}
overlay.findViewById<ScrollView>(R.id.player_subtitle_downloads_scroll)?.apply {
layoutParams = layoutParams.apply {
height = menuRowsHeight(overlay.context, downloads.entries.size, 4)
}
}
}
private fun menuRowsHeight(context: Context, rows: Int, maximumRows: Int): Int {
val density = context.resources.displayMetrics.density
return (rows.coerceIn(1, maximumRows) * 42 * density).toInt()
}
/**
@@ -145,7 +159,7 @@ private fun subtitleMenuOption(
fun dp(value: Int) = (value * density).toInt()
layoutParams = LinearLayout.LayoutParams(
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
dp(if (chip) 40 else 46),
dp(if (chip) 36 else 40),
).apply {
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
}
@@ -159,11 +173,11 @@ private fun subtitleMenuOption(
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
}
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
setPadding(dp(14), 0, dp(14), 0)
setPadding(dp(12), 0, dp(12), 0)
text = entry.label
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
textSize = if (chip) 14f else 15f
textSize = if (chip) 13f else 14f
typeface = Typeface.create("sans-serif", if (entry.selected) Typeface.BOLD else Typeface.NORMAL)
// A disabled row keeps its place and its label and only stops being reachable, so a
// search in flight cannot be started twice and nothing moves while it runs.
@@ -128,6 +128,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
internal data class ChoiceOption(val value: String, val label: String, val color: Color? = null)
@@ -1151,10 +1155,17 @@ internal fun deviceDescription(device: GatewayDevice): String = buildList {
if (device.current) add("This TV")
device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") }
device.lastSeenAt.takeIf(String::isNotBlank)?.let {
add("Last active ${it.replace('T', ' ').substringBefore('.').removeSuffix("Z")}")
add("Last active ${formatDeviceLastSeen(it)}")
}
}.joinToString("").ifBlank { "Signed in" }
internal fun formatDeviceLastSeen(raw: String, zoneId: ZoneId = ZoneId.systemDefault()): String =
runCatching {
DateTimeFormatter.ofPattern("d MMM yyyy, h:mm a")
.withLocale(Locale.forLanguageTag("en-NZ"))
.format(Instant.parse(raw.trim()).atZone(zoneId))
}.getOrDefault(raw)
@Composable
private fun DeviceManagementRow(
device: GatewayDevice,
@@ -1994,7 +2005,7 @@ private fun ReleaseHistoryRow(
}
Spacer(Modifier.weight(1f))
release.date.takeIf(String::isNotBlank)?.let {
Text(it, color = TextQuiet, fontSize = 12.sp)
Text(formatReleaseDate(it), color = TextQuiet, fontSize = 12.sp)
}
Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
@@ -13,6 +13,22 @@ internal data class ReleaseNote(
val changes: List<String>,
)
private val ReleaseMonths = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
/** Turns the changelog's wire-friendly ISO date into the day-first date shown in About. */
internal fun formatReleaseDate(raw: String): String {
val date = raw.trim()
if (date.length != 10 || date[4] != '-' || date[7] != '-') return raw
val year = date.substring(0, 4).toIntOrNull() ?: return raw
val month = date.substring(5, 7).toIntOrNull() ?: return raw
val day = date.substring(8, 10).toIntOrNull() ?: return raw
if (month !in 1..12 || day !in 1..31) return raw
return "$day ${ReleaseMonths[month - 1]} $year"
}
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
/**
@@ -6,7 +6,7 @@
what caps how tall the track list can grow before it scrolls.
The panel holds two screens, not three sections. Choosing a track and fetching one the
title does not have are separate questions, and at 344dp by a third of a 720p screen
title does not have are separate questions, and at 316dp by a third of a 720p screen
there is not room to ask both — stacking them squeezed the track list to a single row.
So `player_subtitle_main_section` and the results half of the download section swap:
the search row is the way in, Back is the way out, one press per level. -->
@@ -21,7 +21,7 @@
<LinearLayout
android:id="@+id/player_subtitle_menu"
android:layout_width="344dp"
android:layout_width="316dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginTop="96dp"
@@ -30,9 +30,9 @@
android:background="@drawable/player_menu_background"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="14dp"
android:paddingTop="10dp"
android:paddingEnd="10dp"
android:paddingBottom="12dp">
android:paddingBottom="10dp">
<LinearLayout
android:id="@+id/player_subtitle_main_section"
@@ -67,10 +67,10 @@
android:visibility="gone" />
<ScrollView
android:id="@+id/player_subtitle_tracks_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:layout_marginTop="6dp"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
@@ -86,7 +86,7 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="10dp"
android:layout_marginTop="7dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
@@ -94,7 +94,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:letterSpacing="0.12"
android:text="@string/player_text_size"
android:textColor="#7AFFFFFF"
@@ -105,7 +105,7 @@
android:id="@+id/player_subtitle_sizes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginTop="6dp"
android:orientation="horizontal" />
</LinearLayout>
@@ -115,7 +115,6 @@
android:id="@+id/player_subtitle_download_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone">
@@ -127,7 +126,7 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
@@ -135,7 +134,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_download"
android:textColor="#7AFFFFFF"
@@ -154,10 +153,10 @@
android:visibility="gone" />
<ScrollView
android:id="@+id/player_subtitle_downloads_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:layout_marginTop="6dp"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
@@ -32,6 +32,14 @@ class SubtitlePreferenceTest {
assertEquals("klingon", normalizeSubtitleLanguage("Klingon"))
}
@Test
fun `subtitle labels include a country flag when the language is known`() {
assertEquals("🇮🇹 Italian", subtitleLabelWithFlag("ita", "Italian"))
assertEquals("🇬🇧 English", subtitleLabelWithFlag("en-GB", "English"))
assertEquals("🇧🇷 Português", subtitleLabelWithFlag("pt-BR", "Português"))
assertEquals("Klingon", subtitleLabelWithFlag("tlh", "Klingon"))
}
@Test
fun `subtitles turned off means nothing is selected`() {
val tracks = listOf(track("1", "eng", default = true))
@@ -16,4 +16,10 @@ class ProfileInitialsTest {
assertEquals("AL", profileInitials("alwyn"))
assertEquals("?", profileInitials(" "))
}
@Test
fun `profile destination names the signed in viewer`() {
assertEquals("Matt Cohen", profileDestinationLabel(" Matt Cohen "))
assertEquals("User", profileDestinationLabel(" "))
}
}
@@ -50,7 +50,7 @@ class UserSwitcherNavigationTest {
fun `both pinned actions are reachable below the profiles`() {
val profileCount = 3
// …the last profile, then My Alerts, then Manage users, and no further.
// …the last profile, then Notifications, then Manage users, and no further.
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
@@ -31,11 +31,11 @@ class AlertsFormatTest {
}
@Test
fun `the summary counts alerts, and names the new ones only when there are some`() {
fun `the summary counts notifications, and names the new ones only when there are some`() {
assertEquals("Nothing waiting for you", alertsSummary(total = 0, unread = 0))
assertEquals("1 alert", alertsSummary(total = 1, unread = 0))
assertEquals("4 alerts", alertsSummary(total = 4, unread = 0))
assertEquals("4 alerts · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 alert · 1 new", alertsSummary(total = 1, unread = 1))
assertEquals("1 notification", alertsSummary(total = 1, unread = 0))
assertEquals("4 notifications", alertsSummary(total = 4, unread = 0))
assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1))
}
}
@@ -16,7 +16,7 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders My Alerts to PNGs under `build/screenshots/my-alerts/`, so the page can be looked
* Renders Notifications to PNGs under `build/screenshots/my-alerts/`, so the page can be looked
* at without deploying to a TV.
*
* ```powershell
@@ -0,0 +1,36 @@
package com.ponzischeme89.memby.ui.player
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec
import org.junit.Assert.assertEquals
import org.junit.Test
class AudioPassthroughOsdTest {
@Test
fun `summary distinguishes automatic off and manual formats`() {
assertEquals("Auto", passthroughOsdSummary(AudioPassthroughPreference.AUTOMATIC))
assertEquals(
"Off",
passthroughOsdSummary(AudioPassthroughPreference(AudioPassthroughMode.MANUAL)),
)
assertEquals(
"Dolby Digital",
passthroughOsdSummary(
AudioPassthroughPreference(
AudioPassthroughMode.MANUAL,
setOf(SurroundCodec.AC3),
),
),
)
assertEquals(
"2 formats",
passthroughOsdSummary(
AudioPassthroughPreference(
AudioPassthroughMode.MANUAL,
setOf(SurroundCodec.AC3, SurroundCodec.DTS),
),
),
)
}
}
@@ -41,9 +41,9 @@ class SubtitleMenuScreenshotTest {
name = "subtitles-menu-track-selected",
tracks = listOf(
entry("Off"),
entry("English", selected = true),
entry("English · SDH"),
entry("Italian · Forced"),
entry("🇬🇧 English", selected = true),
entry("🇬🇧 English · SDH"),
entry("🇮🇹 Italian · Forced"),
),
focused = 1,
)
@@ -54,7 +54,7 @@ class SubtitleMenuScreenshotTest {
fun `subtitles off`() {
capture(
name = "subtitles-menu-off",
tracks = listOf(entry("Off", selected = true), entry("English"), entry("Français")),
tracks = listOf(entry("Off", selected = true), entry("🇬🇧 English"), entry("🇫🇷 Français")),
focused = 0,
)
}
@@ -64,7 +64,7 @@ class SubtitleMenuScreenshotTest {
fun `text size focused`() {
capture(
name = "subtitles-menu-text-size",
tracks = listOf(entry("Off"), entry("English", selected = true)),
tracks = listOf(entry("Off"), entry("🇬🇧 English", selected = true)),
focusedSize = 2,
)
}
@@ -79,14 +79,14 @@ class SubtitleMenuScreenshotTest {
name = "subtitles-menu-long-list",
tracks = listOf(
entry("Off"),
entry("English", selected = true),
entry("English · SDH"),
entry("Français"),
entry("Deutsch · Forced"),
entry("Italiano"),
entry("Español · Default"),
entry("Português (Brasil)"),
entry("Nederlands · SDH · Burned in"),
entry("🇬🇧 English", selected = true),
entry("🇬🇧 English · SDH"),
entry("🇫🇷 Français"),
entry("🇩🇪 Deutsch · Forced"),
entry("🇮🇹 Italiano"),
entry("🇪🇸 Español · Default"),
entry("🇧🇷 Português (Brasil)"),
entry("🇳🇱 Nederlands · SDH · Burned in"),
),
focused = 1,
)
@@ -101,7 +101,7 @@ class SubtitleMenuScreenshotTest {
fun `download offered with nothing suitable in the list`() {
capture(
name = "subtitles-menu-download-offered",
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
downloads = SubtitleDownloadState(
available = true,
entries = listOf(entry("Search for subtitles…", prominent = true)),
@@ -134,16 +134,16 @@ class SubtitleMenuScreenshotTest {
fun `download results`() {
capture(
name = "subtitles-menu-download-results",
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
downloads = SubtitleDownloadState(
available = true,
expanded = true,
entries = listOf(
entry("Search again", prominent = true),
entry("English · 98% match"),
entry("English · Hearing impaired · 96% match"),
entry("English · Forced · 91% match"),
entry("Italian · 84% match"),
entry("🇬🇧 English · 98% match"),
entry("🇬🇧 English · Hearing impaired · 96% match"),
entry("🇬🇧 English · Forced · 91% match"),
entry("🇮🇹 Italian · 84% match"),
),
),
focusedDownload = 1,
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.ui.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
class VersionHistoryTest {
@@ -69,4 +70,19 @@ class VersionHistoryTest {
releases.map { it.version },
)
}
@Test
fun `release dates are shown day first`() {
assertEquals("11 Aug 2026", formatReleaseDate("2026-08-11"))
assertEquals("unknown", formatReleaseDate("unknown"))
}
@Test
fun `device activity is shown in local day first format`() {
assertEquals(
"11 Aug 2026, 10:05 pm",
formatDeviceLastSeen("2026-08-11T10:05:00Z", ZoneId.of("Pacific/Auckland")),
)
assertEquals("not-a-date", formatDeviceLastSeen("not-a-date", ZoneId.of("UTC")))
}
}
@@ -1,6 +1,10 @@
package com.ponzischeme89.memby.ui.whatsnew
import com.ponzischeme89.memby.ui.MEMBY_UPDATE_NOTIFICATION_ID
import com.ponzischeme89.memby.ui.membyUpdateNotification
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
class WhatsNewTest {
@@ -49,4 +53,14 @@ class WhatsNewTest {
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
}
@Test
fun `an installed update becomes a local Notifications entry`() {
val alert = membyUpdateNotification("0.2.52", "2026-08-11T10:00:00Z", read = false)!!
assertEquals(MEMBY_UPDATE_NOTIFICATION_ID, alert.id)
assertEquals("Memby updated", alert.title)
assertEquals("This TV is now running Memby 0.2.52.", alert.message)
assertFalse(alert.readAt != null)
assertNull(membyUpdateNotification(" ", null, read = false))
}
}
+32 -8
View File
@@ -13,8 +13,8 @@ The remote deployment:
- 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;
- tells every signed-in television a deployment is starting, through the
gateway that is still running, before the build begins;
- unless --Quiet is supplied, tells every signed-in television a deployment
is starting through the gateway that is still running before the build begins;
- 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.
@@ -38,6 +38,9 @@ This deploys the current local working tree, including uncommitted server change
.EXAMPLE
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
.EXAMPLE
.\deploy-server.ps1 --Quiet
#>
#Requires -Version 7.2
@@ -82,16 +85,27 @@ param(
[Alias('m')]
[switch] $MandatoryUpdate,
# PowerShell advanced scripts do not bind GNU-style double-dash switches. Accept
# --m explicitly as the sole positional argument so it can still sit at the end.
[Parameter()]
[switch] $Quiet,
# PowerShell advanced scripts do not bind GNU-style double-dash switches by name.
# Accept the two documented flags in either positional order instead.
[Parameter(Position = 0)]
[ValidateSet('--m')]
[string] $MandatoryFlag
[string] $TrailingFlag0,
[Parameter(Position = 1)]
[string] $TrailingFlag1
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$mandatoryRelease = $MandatoryUpdate -or $MandatoryFlag -eq '--m'
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$unknownFlags = @($trailingFlags | Where-Object { $_ -notin @('--m', '--Quiet', '--quiet') })
if ($unknownFlags.Count -gt 0) {
throw "Unknown deployment option: $($unknownFlags -join ', ')"
}
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
if ($mandatoryRelease -and $SkipAppRelease) {
throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
@@ -108,6 +122,9 @@ function Write-Banner {
if ($mandatoryRelease) {
Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow
}
if ($quietDeployment) {
Write-Host '│ Notice quiet (no advance television announcement)' -ForegroundColor DarkGray
}
Write-Host '╰─' -ForegroundColor Magenta
Write-Host ''
}
@@ -534,6 +551,7 @@ destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
@@ -750,7 +768,9 @@ step '[remote 4/10] Telling the televisions'
#
# Entirely best-effort: a deployment must never fail over a banner, and a first
# deployment (or one onto a host with no curl) has nothing to announce with.
if [ -z "$previous_admin_token" ]; then
if [ "$quiet_deployment" -eq 1 ]; then
detail 'Quiet deployment; skipping the television announcement'
elif [ -z "$previous_admin_token" ]; then
detail 'No previous release to announce from; skipping'
elif ! command -v curl >/dev/null 2>&1; then
detail 'curl is unavailable on the NAS; skipping the announcement'
@@ -895,6 +915,10 @@ success 'Memby gateway: https://mserver.sublogue.com'
'__MANDATORY_UPDATE__',
$(if ($mandatoryRelease) { '1' } else { '0' })
)
$remoteCommand = $remoteCommand.Replace(
'__QUIET_DEPLOYMENT__',
$(if ($quietDeployment) { '1' } else { '0' })
)
# 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
+3
View File
@@ -190,6 +190,9 @@ func run(log *slog.Logger, events *logging.Buffer) error {
go server.WatchMaintenance(ctx, 30*time.Second)
// One probe per gateway, not per TV: the answer is the same for the whole house.
go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval)
// One Sonarr catalogue reading per day records lifecycle changes for the household and
// materialises cancellation notifications for every known viewer.
go server.WatchSonarrLifecycle(ctx, 24*time.Hour)
if err := server.LoadUpdatePolicy(ctx); err != nil {
return err
+4
View File
@@ -68,6 +68,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy))
mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
@@ -264,6 +266,7 @@ type adminStatus struct {
ForYouRunning bool `json:"forYouRunning"`
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
HeroPolicy heroAdminPolicy `json:"heroPolicy"`
MDBList mdblistAdminSettings `json:"mdblist"`
Subtitles subtitleAdminSettings `json:"subtitles"`
Features featureResponse `json:"features"`
@@ -329,6 +332,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
}
return policy
}(),
HeroPolicy: s.heroAdminPolicy(ctx),
Subtitles: s.subtitleAdminSettings(ctx),
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
RequestUsers: requestUsers,
+30
View File
@@ -0,0 +1,30 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="star" data-icon-tone="note">Pinned titles</h2>
<p class="card-note">Pinned films and series lead the four-card launcher grid in this order. Empty
places are filled by Membys existing mix of recent digital releases, premieres and
highly rated library titles. Pinning changes placement only; labels and reasons remain natural.</p>
</div>
<div id="hero-pins"></div>
<label class="field"><span>Prime-card subtitle</span>
<em>Optional wording under the large first card. Leave blank to use Membys natural release or rating reason.</em>
<input id="hero-prime-subtitle" type="text" maxlength="160"
placeholder="Leave blank for the automatic reason"></label>
<div class="card-foot">
<button class="primary" id="hero-save">Save hero</button>
<button id="hero-clear">Clear pins</button>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="search" data-icon-tone="info">Find a title</h2>
<p class="card-note">Search the imported Emby catalogue. Up to four films or series can be pinned.</p>
</div>
<div class="row">
<label class="field"><span>Title</span>
<input id="hero-query" type="search" placeholder="Search films and television shows"></label>
<button id="hero-search">Search</button>
</div>
<div class="grid" id="hero-results"></div>
</section>
+75
View File
@@ -0,0 +1,75 @@
const { fmt, ui, $ } = Admin;
let pins = [];
let pinsDirty = false;
function renderPins() {
$('hero-pins').innerHTML = pins.length
? '<div class="chips">' + pins.map((item, index) =>
'<button class="quiet small" data-remove="' + fmt.escape(item.id) + '">' +
(index + 1) + '. ' + fmt.escape(item.name) + (item.year ? ' (' + item.year + ')' : '') +
' · remove</button>').join('') + '</div>'
: ui.empty('No titles are pinned. The hero is entirely release-aware and automatic.');
for (const button of $('hero-pins').querySelectorAll('[data-remove]')) {
button.addEventListener('click', () => {
pins = pins.filter((item) => item.id !== button.dataset.remove);
pinsDirty = true;
renderPins();
});
}
}
Admin.onStatus((status) => {
if (pinsDirty || !Admin.settled($('hero-pins'))) return;
pins = (status.heroPolicy?.pinnedItems || []).slice(0, 4);
Admin.fill($('hero-prime-subtitle'), status.heroPolicy?.primeSubtitle || '');
renderPins();
});
async function search() {
const query = $('hero-query').value.trim();
if (!query) return;
const payload = await Admin.api('/admin/api/hero/search?q=' + encodeURIComponent(query));
const items = payload.items || [];
$('hero-results').innerHTML = items.length ? items.map((item) =>
'<section class="card"><h2 class="card-title">' + fmt.escape(item.name) + '</h2>' +
'<p class="card-note">' + fmt.escape(item.type || 'Title') + ' · ' +
(item.year || 'Year unknown') + '</p>' +
'<button data-add="' + fmt.escape(item.id) + '">Add to hero</button></section>').join('')
: ui.empty('No playable films or series matched that search.');
for (const button of $('hero-results').querySelectorAll('[data-add]')) {
button.addEventListener('click', () => {
const selected = items.find((item) => item.id === button.dataset.add);
if (!selected || pins.some((item) => item.id === selected.id)) return;
if (pins.length >= 4) {
Admin.error('Remove a pinned film before adding another.');
return;
}
pins.push(selected);
pinsDirty = true;
renderPins();
Admin.error('');
});
}
}
Admin.ready(() => {
$('hero-search').addEventListener('click', () => Admin.act(search));
$('hero-query').addEventListener('keydown', (event) => {
if (event.key === 'Enter') Admin.act(search);
});
$('hero-save').addEventListener('click', () => Admin.act(async () => {
await Admin.api('/admin/api/hero-policy', {
method: 'POST', body: JSON.stringify({
pinnedItemIds: pins.map((item) => item.id),
primeSubtitle: $('hero-prime-subtitle').value.trim(),
}),
});
pinsDirty = false;
}));
$('hero-clear').addEventListener('click', () => {
pins = [];
pinsDirty = true;
renderPins();
});
$('hero-prime-subtitle').addEventListener('input', () => { pinsDirty = true; });
});
+5
View File
@@ -112,6 +112,11 @@ var adminNav = []adminNavGroup{
{
Label: "Experience",
Items: []adminNavItem{
{
ID: "hero", Label: "Home hero", Title: "Home hero",
Intro: "Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",
Icon: "m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z",
},
{
ID: "features", Label: "Features", Title: "Features",
Intro: "Roll out, stop and recover optional behaviour with no app release.",
+100 -3
View File
@@ -57,6 +57,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
const (
@@ -134,6 +135,7 @@ type heroKind int
const (
heroMovie heroKind = iota
heroSeries
heroSeriesPremiere
heroSeasonPremiere
)
@@ -345,6 +347,8 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeriesPremiere:
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeries && fresh:
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeasonPremiere:
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
case fresh && acclaimed && candidate.Estimated:
@@ -363,6 +367,19 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
}
}
func heroReasonForPosition(
candidate heroCandidate,
position int,
primeSubtitle string,
now time.Time,
location *time.Location,
) string {
if position == 0 && strings.TrimSpace(primeSubtitle) != "" {
return strings.TrimSpace(primeSubtitle)
}
return heroReason(candidate, now, location)
}
// heroWhen words a date the way somebody would say it out loud. Nothing here is more
// precise than the evidence: a digital release date carries no time of day, so a card
// never claims an hour.
@@ -648,23 +665,30 @@ func (s *Server) heroRow(
location = time.Local
}
candidates := s.heroCandidates(ctx, rows, now)
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
policy = store.HeroPolicy{}
}
pinned := s.pinnedHeroCandidates(ctx, policy.PinnedItemIDs)
// Rank a pool, then draw the row out of it. Ranking straight to the row's length is
// what made the hero the same four cards for a week — see rotateHeroCandidates.
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
ranked := rotateHeroCandidates(
organic := rotateHeroCandidates(
pool,
heroVariationSeed(userID, heroRotationSlot(now, location)),
heroRowLimit,
)
ranked := mergePinnedHeroCandidates(pinned, candidates, organic, heroRowLimit)
if len(ranked) == 0 {
return nil
}
items := make([]json.RawMessage, 0, len(ranked))
for _, candidate := range ranked {
for index, candidate := range ranked {
items = append(items, injectHeroFields(
candidate.Item,
heroLabel(candidate, now),
heroReason(candidate, now, location),
heroReasonForPosition(candidate, index, policy.PrimeSubtitle, now, location),
))
}
return &recommend.Row{
@@ -675,6 +699,79 @@ func (s *Server) heroRow(
}
}
// mergePinnedHeroCandidates places explicit operator choices in the visible grid first,
// then fills it with the normal release-aware rotation. When a pin also qualified
// organically, its release or premiere evidence is retained so pinning changes placement,
// never presentation.
func mergePinnedHeroCandidates(
pinned, evidence, organic []heroCandidate,
limit int,
) []heroCandidate {
if limit <= 0 {
return nil
}
out := make([]heroCandidate, 0, limit)
seen := map[string]bool{}
evidenceByID := make(map[string]heroCandidate, len(evidence))
for _, candidate := range evidence {
evidenceByID[candidate.ID] = candidate
}
for index, candidate := range pinned {
if natural, ok := evidenceByID[candidate.ID]; ok {
pinned[index] = natural
}
}
appendUnique := func(candidates []heroCandidate) {
for _, candidate := range candidates {
if len(out) == limit {
return
}
if candidate.ID == "" || len(candidate.Item) == 0 || seen[candidate.ID] {
continue
}
seen[candidate.ID] = true
out = append(out, candidate)
}
}
appendUnique(pinned)
appendUnique(organic)
return out
}
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
items, err := s.store.LibraryItemsByID(ctx, ids)
if err != nil {
s.loggerFor(ctx).Warn("pinned hero titles unavailable", "error", err)
return nil
}
byID := make(map[string]heroCandidate, len(items))
for _, raw := range items {
fact, ok := heroFactsOf(raw)
if !ok || !fact.Playable ||
(!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) {
continue
}
rating, rated := heroRatingOf(raw)
kind := heroMovie
if strings.EqualFold(fact.Type, "Series") {
kind = heroSeries
}
byID[fact.ID] = heroCandidate{
ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw,
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated,
}
}
out := make([]heroCandidate, 0, len(ids))
for _, id := range ids {
if candidate, ok := byID[id]; ok {
out = append(out, candidate)
}
}
return out
}
// heroCandidates gathers everything eligible, premieres first.
//
// Premieres lead the input order so that they win a tie against a film of identical
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
type heroAdminItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Year int `json:"year,omitempty"`
}
type heroAdminPolicy struct {
PinnedItems []heroAdminItem `json:"pinnedItems"`
PrimeSubtitle string `json:"primeSubtitle"`
}
func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) {
fact, ok := heroFactsOf(raw)
if !ok || !fact.Playable ||
(!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) {
return heroAdminItem{}, false
}
return heroAdminItem{ID: fact.ID, Name: fact.Name, Type: fact.Type, Year: heroYearOf(fact)}, true
}
func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable to admin", "error", err)
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
}
items, err := s.store.LibraryItemsByID(ctx, policy.PinnedItemIDs)
if err != nil {
s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err)
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
}
byID := make(map[string]heroAdminItem, len(items))
for _, raw := range items {
if item, ok := adminHeroItem(raw); ok {
byID[item.ID] = item
}
}
out := heroAdminPolicy{
PinnedItems: make([]heroAdminItem, 0, len(policy.PinnedItemIDs)),
PrimeSubtitle: policy.PrimeSubtitle,
}
for _, id := range policy.PinnedItemIDs {
if item, ok := byID[id]; ok {
out.PinnedItems = append(out.PinnedItems, item)
}
}
return out
}
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
if err != nil {
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not search the library")
return
}
results := make([]heroAdminItem, 0, len(items))
for _, raw := range items {
if item, ok := adminHeroItem(raw); ok {
results = append(results, item)
}
}
writeJSON(w, http.StatusOK, map[string]any{"items": results})
}
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
var request struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
PrimeSubtitle string `json:"primeSubtitle"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid hero policy")
return
}
ids := uniqueHeroIDs(request.PinnedItemIDs)
if len(ids) > 4 {
writeError(w, http.StatusBadRequest, "the hero can pin at most four titles")
return
}
items, err := s.store.LibraryItemsByID(r.Context(), ids)
if err != nil {
s.loggerFor(r.Context()).Error("hero title validation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not validate hero titles")
return
}
valid := map[string]bool{}
for _, raw := range items {
if item, ok := adminHeroItem(raw); ok {
valid[item.ID] = true
}
}
for _, id := range ids {
if !valid[id] {
writeError(w, http.StatusBadRequest, "every pinned item must be a playable library film or series")
return
}
}
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle}
if err := s.store.SetHeroPolicy(r.Context(), policy); err != nil {
s.loggerFor(r.Context()).Error("hero policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save hero policy")
return
}
s.invalidateAllHomeCaches(r.Context())
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
}
func uniqueHeroIDs(ids []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
func (s *Server) invalidateAllHomeCaches(ctx context.Context) {
users, err := s.store.KnownUsers(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero cache invalidation could not list users", "error", err)
return
}
for _, user := range users {
if err := s.cache.InvalidateUser(ctx, user.ID); err != nil {
s.loggerFor(ctx).Warn("hero cache invalidation failed", "user", user.ID, "error", err)
}
}
}
+72
View File
@@ -537,6 +537,78 @@ func TestHeroRotationSlotIsLocal(t *testing.T) {
}
}
func TestPinnedHeroMoviesLeadAndOrganicSelectionFillsTheGrid(t *testing.T) {
pinned := []heroCandidate{
{ID: "custom-2", Item: json.RawMessage(`{"Id":"custom-2"}`)},
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`)},
}
organic := []heroCandidate{
// A pinned title can also qualify organically; it must still appear only once.
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`), Kind: heroSeasonPremiere},
{ID: "release-1", Item: json.RawMessage(`{"Id":"release-1"}`)},
{ID: "release-2", Item: json.RawMessage(`{"Id":"release-2"}`)},
{ID: "library-1", Item: json.RawMessage(`{"Id":"library-1"}`)},
}
got := mergePinnedHeroCandidates(pinned, organic, organic[1:], 4)
want := []string{"custom-2", "custom-1", "release-1", "release-2"}
if strings.Join(heroIDs(got), ",") != strings.Join(want, ",") {
t.Fatalf("pinned hero order = %v, want %v", heroIDs(got), want)
}
if got[1].Kind != heroSeasonPremiere {
t.Fatalf("pin lost its natural premiere evidence: %+v", got[1])
}
}
func TestPinnedHeroMergeHonoursAnEmptyOrShortGrid(t *testing.T) {
candidate := heroCandidate{ID: "custom", Item: json.RawMessage(`{"Id":"custom"}`)}
if got := mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 0); got != nil {
t.Fatalf("zero-sized grid = %v, want nil", heroIDs(got))
}
if got := heroIDs(mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 4)); strings.Join(got, ",") != "custom" {
t.Fatalf("short grid = %v, want custom", got)
}
}
func TestPrimeSubtitleChangesOnlyTheLargeFirstCard(t *testing.T) {
candidate := heroCandidate{ReleasedAt: heroDaysAgo(2), Kind: heroMovie}
if got := heroReasonForPosition(candidate, 0, " Family pick tonight ", heroNow, time.UTC); got != "Family pick tonight" {
t.Fatalf("prime subtitle = %q", got)
}
if got := heroReasonForPosition(candidate, 1, "Family pick tonight", heroNow, time.UTC); got == "Family pick tonight" || !strings.Contains(got, "Released") {
t.Fatalf("secondary card lost its natural reason: %q", got)
}
if got := heroReasonForPosition(candidate, 0, "", heroNow, time.UTC); !strings.Contains(got, "Released") {
t.Fatalf("blank override did not fall back to the natural reason: %q", got)
}
}
func TestPinnedSeriesUsesTheSameNaturalPresentationAsOrganicTitles(t *testing.T) {
candidate := heroCandidate{Kind: heroSeries, ReleasedAt: heroDaysAgo(3)}
if got := heroLabel(candidate, heroNow); got != heroLabelNewRelease {
t.Fatalf("recent series label = %q, want %q", got, heroLabelNewRelease)
}
if got := heroReason(candidate, heroNow, time.UTC); !strings.Contains(got, "new series premiered") {
t.Fatalf("recent series reason = %q", got)
}
candidate.ReleasedAt = heroDaysAgo(100)
candidate.Rated, candidate.Rating = true, 0.9
if got := heroLabel(candidate, heroNow); got != heroLabelAcclaimed {
t.Fatalf("older acclaimed series label = %q, want %q", got, heroLabelAcclaimed)
}
}
func TestAdminHeroSearchAcceptsFilmsAndSeriesOnly(t *testing.T) {
for _, itemType := range []string{"Movie", "Series"} {
if item, ok := adminHeroItem(heroItem("id", "Title", itemType)); !ok || item.Type != itemType {
t.Fatalf("%s was not accepted: %+v, %t", itemType, item, ok)
}
}
if _, ok := adminHeroItem(heroItem("episode", "Episode", "Episode")); ok {
t.Fatal("episode was accepted as a pinnable hero title")
}
}
func heroIDs(candidates []heroCandidate) []string {
ids := make([]string, 0, len(candidates))
for _, candidate := range candidates {
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
// WatchSonarrLifecycle seeds the durable status history on startup, then refreshes it
// daily. History stores changes rather than identical daily snapshots: it still records
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
if s.sonarr == nil || interval <= 0 {
return
}
scan := func() {
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
}
}
scan()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
scan()
}
}
}
func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
series, err := s.sonarr.Series(ctx)
if err != nil {
return fmt.Errorf("read Sonarr series: %w", err)
}
now := time.Now().UTC()
observations := make([]store.SonarrSeriesStatus, 0, len(series))
for _, item := range series {
key := sonarrSeriesStatusKey(item)
if key == "" || strings.TrimSpace(item.Status) == "" {
continue
}
observations = append(observations, store.SonarrSeriesStatus{
SeriesKey: key, SonarrSeriesID: item.ID, TVDBID: item.TVDBID,
Title: item.Title, Year: item.Year, Status: item.Status, ObservedAt: now,
})
}
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
if err != nil {
return err
}
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
for _, change := range changes {
if sonarrBecameCancelled(change.PreviousStatus, change.Current.Status) {
cancellations = append(cancellations, change)
}
}
if len(cancellations) == 0 {
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
return nil
}
users, err := s.store.KnownUsers(ctx)
if err != nil {
return err
}
preferences := map[string]store.NotificationPreferences{}
preferenceErrors := map[string]bool{}
notifications := 0
for _, change := range cancellations {
for _, user := range users {
prefs, ok := preferences[user.ID]
if !ok && !preferenceErrors[user.ID] {
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
if err != nil {
preferenceErrors[user.ID] = true
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
"user", user.ID, "error", err)
continue
}
preferences[user.ID] = prefs
}
if preferenceErrors[user.ID] || !prefs.Enabled {
continue
}
eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
message := change.Current.Title + " is now listed as cancelled in Sonarr."
if err := s.store.UpsertNotification(
ctx, user.ID, sourceKey, "show-cancelled", "",
"Show cancelled", message, &eventAt,
); err != nil {
s.log.Warn("Sonarr cancellation notification failed",
"user", user.ID, "show", change.Current.Title, "error", err)
continue
}
notifications++
}
}
s.log.Info("Sonarr lifecycle scan complete",
"series", len(observations), "changes", len(changes),
"cancelled", len(cancellations), "notifications", notifications)
return nil
}
func sonarrSeriesStatusKey(series sonarr.Series) string {
if series.TVDBID > 0 {
return "tvdb:" + strconv.Itoa(series.TVDBID)
}
if series.ID > 0 {
return "sonarr:" + strconv.Itoa(series.ID)
}
return ""
}
func sonarrBecameCancelled(previous, current string) bool {
previous = strings.ToLower(strings.TrimSpace(previous))
current = strings.ToLower(strings.TrimSpace(current))
active := previous == "continuing" || previous == "upcoming"
cancelled := current == "ended" || current == "deleted" ||
current == "cancelled" || current == "canceled"
return active && cancelled
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestSonarrSeriesStatusKeyPrefersTVDBIdentity(t *testing.T) {
series := sonarr.Series{ID: 42, TVDBID: 1234}
if got := sonarrSeriesStatusKey(series); got != "tvdb:1234" {
t.Fatalf("status key = %q, want tvdb:1234", got)
}
series.TVDBID = 0
if got := sonarrSeriesStatusKey(series); got != "sonarr:42" {
t.Fatalf("fallback status key = %q, want sonarr:42", got)
}
}
func TestSonarrCancellationRequiresAnActiveHistory(t *testing.T) {
for _, transition := range [][2]string{
{"continuing", "ended"},
{"upcoming", "deleted"},
{"Continuing", "cancelled"},
} {
if !sonarrBecameCancelled(transition[0], transition[1]) {
t.Errorf("%q -> %q should be a cancellation", transition[0], transition[1])
}
}
for _, transition := range [][2]string{
{"", "ended"}, // first scan is a baseline, not news
{"ended", "ended"}, // an unchanged cancelled show is not announced daily
{"ended", "continuing"},
{"continuing", "continuing"},
} {
if sonarrBecameCancelled(transition[0], transition[1]) {
t.Errorf("%q -> %q should not be a cancellation", transition[0], transition[1])
}
}
}
+96
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"fmt"
"strings"
"time"
)
@@ -31,6 +32,24 @@ type UserNotification struct {
ReadAt *time.Time `json:"readAt,omitempty"`
}
// SonarrSeriesStatus is one daily observation. SeriesKey prefers Sonarr's stable TVDB id;
// the local Sonarr id is retained for diagnosis and as a fallback when TVDB has no answer.
type SonarrSeriesStatus struct {
SeriesKey string
SonarrSeriesID int
TVDBID int
Title string
Year int
Status string
ObservedAt time.Time
}
type SonarrSeriesStatusChange struct {
HistoryID int64
PreviousStatus string
Current SonarrSeriesStatus
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
@@ -89,6 +108,83 @@ func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error
return shows, rows.Err()
}
// RecordSonarrSeriesStatuses appends only first sightings and changes. A first sighting is
// the baseline and is deliberately absent from the returned changes, so enabling the
// scanner cannot announce every series that was already cancelled before it existed.
func (s *Store) RecordSonarrSeriesStatuses(
ctx context.Context, observations []SonarrSeriesStatus,
) ([]SonarrSeriesStatusChange, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("store: begin Sonarr status history: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT DISTINCT ON (series_key) series_key, status
FROM sonarr_series_status_history
ORDER BY series_key, observed_at DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("store: read Sonarr status history: %w", err)
}
previous := map[string]string{}
for rows.Next() {
var key, status string
if err := rows.Scan(&key, &status); err != nil {
rows.Close()
return nil, fmt.Errorf("store: scan Sonarr status history: %w", err)
}
previous[key] = status
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("store: iterate Sonarr status history: %w", err)
}
rows.Close()
changes := []SonarrSeriesStatusChange{}
seen := map[string]bool{}
for _, observation := range observations {
observation.SeriesKey = strings.TrimSpace(observation.SeriesKey)
observation.Title = strings.TrimSpace(observation.Title)
observation.Status = strings.ToLower(strings.TrimSpace(observation.Status))
if observation.SeriesKey == "" || observation.Title == "" || observation.Status == "" ||
seen[observation.SeriesKey] {
continue
}
seen[observation.SeriesKey] = true
prior, known := previous[observation.SeriesKey]
if known && strings.EqualFold(prior, observation.Status) {
continue
}
if observation.ObservedAt.IsZero() {
observation.ObservedAt = time.Now()
}
var historyID int64
err := tx.QueryRow(ctx, `
INSERT INTO sonarr_series_status_history
(series_key, sonarr_series_id, tvdb_id, title, year, status, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
observation.SeriesKey, observation.SonarrSeriesID, observation.TVDBID,
observation.Title, observation.Year, observation.Status, observation.ObservedAt,
).Scan(&historyID)
if err != nil {
return nil, fmt.Errorf("store: append Sonarr status history: %w", err)
}
if known {
changes = append(changes, SonarrSeriesStatusChange{
HistoryID: historyID, PreviousStatus: prior, Current: observation,
})
}
previous[observation.SeriesKey] = observation.Status
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("store: commit Sonarr status history: %w", err)
}
return changes, nil
}
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
err := s.pool.QueryRow(ctx, `
+17
View File
@@ -239,6 +239,23 @@ CREATE TABLE IF NOT EXISTS user_notifications (
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
ON user_notifications (emby_user_id, created_at DESC);
-- A change-only history of Sonarr's lifecycle answer for every series. The first reading
-- is a baseline; later rows mean Sonarr changed its answer, which lets the daily scanner
-- distinguish a show that was already over from one that has just been cancelled.
CREATE TABLE IF NOT EXISTS sonarr_series_status_history (
id BIGSERIAL PRIMARY KEY,
series_key TEXT NOT NULL,
sonarr_series_id INT NOT NULL DEFAULT 0,
tvdb_id INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
year INT NOT NULL DEFAULT 0,
status TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
+71 -1
View File
@@ -18,14 +18,84 @@ const MaintenanceKey = "maintenance"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy"
// PlaybackPolicyKey controls presentation behavior that should be adjustable without
// PlaybackPolicyKey controls presentation behaviour that should be adjustable without
// shipping a new TV build.
const PlaybackPolicyKey = "playback_policy"
// HeroPolicyKey stores the operator's explicit choices for the launcher hero.
const HeroPolicyKey = "hero_policy"
// MDBListSettingsKey stores the optional movie-ratings integration. The API key stays
// in this server-owned document and is never included in client or admin status payloads.
const MDBListSettingsKey = "mdblist_settings"
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
// remain library data, so a metadata correction appears without rewriting operator policy.
type HeroPolicy struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
PrimeSubtitle string `json:"primeSubtitle"`
UpdatedAt time.Time `json:"updatedAt"`
}
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
if len(policy.PinnedItemIDs) == 0 && len(policy.LegacyPinnedMovieIDs) > 0 {
policy.PinnedItemIDs = policy.LegacyPinnedMovieIDs
}
seen := map[string]bool{}
ids := make([]string, 0, min(len(policy.PinnedItemIDs), 4))
for _, id := range policy.PinnedItemIDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] || len(ids) == 4 {
continue
}
seen[id] = true
ids = append(ids, id)
}
policy.PinnedItemIDs = ids
policy.LegacyPinnedMovieIDs = nil
policy.PrimeSubtitle = strings.TrimSpace(policy.PrimeSubtitle)
runes := []rune(policy.PrimeSubtitle)
if len(runes) > 160 {
policy.PrimeSubtitle = string(runes[:160])
}
return policy
}
func (s *Store) HeroPolicy(ctx context.Context) (HeroPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, HeroPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return HeroPolicy{PinnedItemIDs: []string{}}, nil
}
if err != nil {
return HeroPolicy{}, fmt.Errorf("store: read hero policy: %w", err)
}
var policy HeroPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return HeroPolicy{}, fmt.Errorf("store: decode hero policy: %w", err)
}
return normalizeHeroPolicy(policy), nil
}
func (s *Store) SetHeroPolicy(ctx context.Context, policy HeroPolicy) error {
policy = normalizeHeroPolicy(policy)
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
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()`,
HeroPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write hero policy: %w", err)
}
return nil
}
var defaultMDBListSources = []string{
"imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert",
"tmdb", "trakt", "mal", "anilist", "anidb", "kitsu", "score", "score_average",
+26 -1
View File
@@ -25,6 +25,31 @@ func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
}
}
func TestHeroPolicyKeepsFourUniqueTrimmedItemIDsInOrder(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{PinnedItemIDs: []string{
" movie-2 ", "movie-1", "movie-2", "", "movie-3", "movie-4", "movie-5",
}, PrimeSubtitle: " Tonights pick "})
want := []string{"movie-2", "movie-1", "movie-3", "movie-4"}
if len(got.PinnedItemIDs) != len(want) {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
for index := range want {
if got.PinnedItemIDs[index] != want[index] {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
}
if got.PrimeSubtitle != "Tonights pick" {
t.Fatalf("prime subtitle = %q", got.PrimeSubtitle)
}
}
func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{LegacyPinnedMovieIDs: []string{"movie-1"}})
if len(got.PinnedItemIDs) != 1 || got.PinnedItemIDs[0] != "movie-1" {
t.Fatalf("legacy pinned ids = %v", got.PinnedItemIDs)
}
}
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
defaults := DefaultMDBListSettings()
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
@@ -35,7 +60,7 @@ func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
})
if got.APIKey != "secret" || len(got.Sources) != 2 ||
got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" {
t.Fatalf("normalized MDBList settings = %+v", got)
t.Fatalf("normalised MDBList settings = %+v", got)
}
}