0.2.52
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,6 +403,7 @@ 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.
|
||||
if (useTextTitle) {
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
@@ -413,6 +414,15 @@ private fun FeaturedMovieCard(
|
||||
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,28 +550,15 @@ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Memby’s 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 Memby’s 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>
|
||||
@@ -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; });
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, `
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: " Tonight’s 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 != "Tonight’s 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user