From 5fed3360c298c9c9246effd87f6456bf890c8b7d Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Tue, 11 Aug 2026 23:41:10 +1200 Subject: [PATCH] 0.2.52 --- CHANGELOG.md | 17 ++ CLAUDE.md | 10 +- app/build.gradle.kts | 2 +- .../ponzischeme89/memby/data/SettingsStore.kt | 33 +++- .../memby/data/SubtitleSupport.kt | 47 ++++++ .../ponzischeme89/memby/ui/HomeComponents.kt | 28 +++- .../ponzischeme89/memby/ui/HomeMovieHero.kt | 61 ++++---- .../ponzischeme89/memby/ui/MainActivity.kt | 58 +++++-- .../memby/ui/MaintenanceScreen.kt | 2 +- .../ponzischeme89/memby/ui/MembyButtons.kt | 28 ++++ .../memby/ui/UserSwitcherNavigation.kt | 2 +- .../memby/ui/alerts/AlertsFormat.kt | 8 +- .../memby/ui/alerts/AlertsPage.kt | 13 +- .../memby/ui/player/PlayerActivity.kt | 89 ++++++++++- .../memby/ui/player/SubtitleMenu.kt | 22 ++- .../memby/ui/settings/SettingsSheet.kt | 15 +- .../memby/ui/settings/VersionHistory.kt | 16 ++ .../res/layout/player_subtitle_overlay.xml | 27 ++-- .../memby/data/SubtitlePreferenceTest.kt | 8 + .../memby/ui/ProfileInitialsTest.kt | 6 + .../memby/ui/UserSwitcherNavigationTest.kt | 2 +- .../memby/ui/alerts/AlertsFormatTest.kt | 10 +- .../ui/alerts/AlertsPageScreenshotTest.kt | 2 +- .../ui/player/AudioPassthroughOsdTest.kt | 36 +++++ .../ui/player/SubtitleMenuScreenshotTest.kt | 38 ++--- .../memby/ui/settings/VersionHistoryTest.kt | 16 ++ .../memby/ui/whatsnew/WhatsNewTest.kt | 14 ++ deploy-server.ps1 | 40 ++++- server/cmd/memby-server/main.go | 3 + server/internal/api/admin.go | 4 + server/internal/api/admin/pages/hero.html | 30 ++++ server/internal/api/admin/pages/hero.js | 75 +++++++++ server/internal/api/admin_console.go | 5 + server/internal/api/hero.go | 103 ++++++++++++- server/internal/api/hero_admin.go | 145 ++++++++++++++++++ server/internal/api/hero_test.go | 72 +++++++++ server/internal/api/sonarr_status.go | 131 ++++++++++++++++ server/internal/api/sonarr_status_test.go | 40 +++++ server/internal/store/my_shows.go | 96 ++++++++++++ server/internal/store/schema.sql | 17 ++ server/internal/store/settings.go | 72 ++++++++- server/internal/store/settings_test.go | 27 +++- 42 files changed, 1340 insertions(+), 130 deletions(-) create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/player/AudioPassthroughOsdTest.kt create mode 100644 server/internal/api/admin/pages/hero.html create mode 100644 server/internal/api/admin/pages/hero.js create mode 100644 server/internal/api/hero_admin.go create mode 100644 server/internal/api/sonarr_status.go create mode 100644 server/internal/api/sonarr_status_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e11a35b..10947bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 0c7e993..1502abf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1615204..5747e8d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index 8316b40..581384b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SubtitleSupport.kt b/app/src/main/java/com/ponzischeme89/memby/data/SubtitleSupport.kt index 780f417..fb8840f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SubtitleSupport.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SubtitleSupport.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index a76dd79..de4662c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 060c2a8..e211fa3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -2,7 +2,6 @@ package com.ponzischeme89.memby.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -16,10 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -30,7 +26,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -43,7 +38,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage import com.ponzischeme89.memby.ServiceLocator @@ -345,6 +339,12 @@ private fun FeaturedMovieCard( previewArtwork: ImageBitmap?, ) { val item = pick.item + val repository = ServiceLocator.repository + val showLogo = repository.showTitleLogo + val logoUrl = remember(item.id, item.imageTags, item.parentLogoItemId, item.parentLogoImageTag, showLogo) { + if (showLogo) repository.logoUrl(item, 640) else null + } + val useTextTitle = useTextTitleForLogo(logoUrl) FocusScaleContainer( onFocused = onFocused, onClick = onClick, @@ -403,16 +403,26 @@ private fun FeaturedMovieCard( // on the least specific thing on it — and it is the line that pushed a // wrapped title into the button. The three minis beside it keep theirs: // they have no fact line, and there the label is the only reason given. - Text( - item.name, - color = Color.White, - fontSize = 30.sp, - lineHeight = 32.sp, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - onTextLayout = { titleLines = it.lineCount }, - ) + if (useTextTitle) { + Text( + item.name, + color = Color.White, + fontSize = 30.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + onTextLayout = { titleLines = it.lineCount }, + ) + } else { + AsyncImage( + model = logoUrl, + contentDescription = item.name, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + modifier = Modifier.width(240.dp).height(56.dp), + ) + } Spacer(Modifier.height(9.dp)) HeroFactLine(item) // The reason takes the synopsis's place rather than adding a line to @@ -540,24 +550,11 @@ private fun MiniMovieCard( } } if (focused) { - Box( + MembyArtworkPlayCue( modifier = Modifier .align(Alignment.CenterEnd) - .padding(end = 16.dp) - .size(38.dp) - .shadow(14.dp, CircleShape) - .clip(CircleShape) - .background(MembyAccent) - .border(2.dp, Color.White, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(24.dp), - ) - } + .padding(end = 16.dp), + ) } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index fbb3160..83a17dc 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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(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 } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt index 711a010..7ccdde3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt @@ -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. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt index 5d0169a..d5be6ae 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt index 409fff8..8492607 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt @@ -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. */ diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt index 8e06445..c3447ee 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt @@ -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" } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index ac61871..4c8c637 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -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 } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 59fcce4..832b6fd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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) { + 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) { + 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) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt index cc56fc7..e755530 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt @@ -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 = 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(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(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. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 222751a..eb73091 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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", diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/VersionHistory.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/VersionHistory.kt index 2d27b2c..fc7387a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/VersionHistory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/VersionHistory.kt @@ -13,6 +13,22 @@ internal data class ReleaseNote( val changes: List, ) +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*(.+))?$""") /** diff --git a/app/src/main/res/layout/player_subtitle_overlay.xml b/app/src/main/res/layout/player_subtitle_overlay.xml index 9148530..4ebe276 100644 --- a/app/src/main/res/layout/player_subtitle_overlay.xml +++ b/app/src/main/res/layout/player_subtitle_overlay.xml @@ -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 @@ + android:paddingBottom="10dp"> @@ -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" /> @@ -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" /> diff --git a/app/src/test/java/com/ponzischeme89/memby/data/SubtitlePreferenceTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/SubtitlePreferenceTest.kt index 7aa525a..0e5aaed 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/SubtitlePreferenceTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/SubtitlePreferenceTest.kt @@ -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)) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ProfileInitialsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ProfileInitialsTest.kt index 8390025..94ed874 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ProfileInitialsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ProfileInitialsTest.kt @@ -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(" ")) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt index 5543c36..0c4d268 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt @@ -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)) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt index 7313d0b..c93e894 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt @@ -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)) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt index 0e541e9..ba33fda 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt @@ -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 diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/AudioPassthroughOsdTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/AudioPassthroughOsdTest.kt new file mode 100644 index 0000000..b5fcb33 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/AudioPassthroughOsdTest.kt @@ -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), + ), + ), + ) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/SubtitleMenuScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/SubtitleMenuScreenshotTest.kt index 2c17765..b75365a 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/SubtitleMenuScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/SubtitleMenuScreenshotTest.kt @@ -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, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/settings/VersionHistoryTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/settings/VersionHistoryTest.kt index 6c15faa..966a71d 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/settings/VersionHistoryTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/settings/VersionHistoryTest.kt @@ -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"))) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt index 59b4620..ffb058f 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt @@ -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)) + } + } diff --git a/deploy-server.ps1 b/deploy-server.ps1 index 8685a90..1bbbcb8 100644 --- a/deploy-server.ps1 +++ b/deploy-server.ps1 @@ -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 diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index 090503a..5716a5b 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -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 diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 46da9da..1c1675d 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -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, diff --git a/server/internal/api/admin/pages/hero.html b/server/internal/api/admin/pages/hero.html new file mode 100644 index 0000000..6b5c1b5 --- /dev/null +++ b/server/internal/api/admin/pages/hero.html @@ -0,0 +1,30 @@ +
+
+

Pinned titles

+

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.

+
+
+ +
+ + +
+
+ +
+
+

Find a title

+

Search the imported Emby catalogue. Up to four films or series can be pinned.

+
+
+ + +
+
+
diff --git a/server/internal/api/admin/pages/hero.js b/server/internal/api/admin/pages/hero.js new file mode 100644 index 0000000..255a9c3 --- /dev/null +++ b/server/internal/api/admin/pages/hero.js @@ -0,0 +1,75 @@ +const { fmt, ui, $ } = Admin; +let pins = []; +let pinsDirty = false; + +function renderPins() { + $('hero-pins').innerHTML = pins.length + ? '
' + pins.map((item, index) => + '').join('') + '
' + : 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) => + '

' + fmt.escape(item.name) + '

' + + '

' + fmt.escape(item.type || 'Title') + ' · ' + + (item.year || 'Year unknown') + '

' + + '
').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; }); +}); diff --git a/server/internal/api/admin_console.go b/server/internal/api/admin_console.go index cf33577..d7e0c5b 100644 --- a/server/internal/api/admin_console.go +++ b/server/internal/api/admin_console.go @@ -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.", diff --git a/server/internal/api/hero.go b/server/internal/api/hero.go index 21836e9..a8946a6 100644 --- a/server/internal/api/hero.go +++ b/server/internal/api/hero.go @@ -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 diff --git a/server/internal/api/hero_admin.go b/server/internal/api/hero_admin.go new file mode 100644 index 0000000..3079584 --- /dev/null +++ b/server/internal/api/hero_admin.go @@ -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) + } + } +} diff --git a/server/internal/api/hero_test.go b/server/internal/api/hero_test.go index 57956af..f5e1b5c 100644 --- a/server/internal/api/hero_test.go +++ b/server/internal/api/hero_test.go @@ -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 { diff --git a/server/internal/api/sonarr_status.go b/server/internal/api/sonarr_status.go new file mode 100644 index 0000000..ab96493 --- /dev/null +++ b/server/internal/api/sonarr_status.go @@ -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 +} diff --git a/server/internal/api/sonarr_status_test.go b/server/internal/api/sonarr_status_test.go new file mode 100644 index 0000000..665c590 --- /dev/null +++ b/server/internal/api/sonarr_status_test.go @@ -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]) + } + } +} diff --git a/server/internal/store/my_shows.go b/server/internal/store/my_shows.go index b4744c1..eeca6ef 100644 --- a/server/internal/store/my_shows.go +++ b/server/internal/store/my_shows.go @@ -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, ` diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index dc518ba..6e706c4 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -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. diff --git a/server/internal/store/settings.go b/server/internal/store/settings.go index dca1899..a2b290a 100644 --- a/server/internal/store/settings.go +++ b/server/internal/store/settings.go @@ -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", diff --git a/server/internal/store/settings_test.go b/server/internal/store/settings_test.go index 0ff12a9..8c89387 100644 --- a/server/internal/store/settings_test.go +++ b/server/internal/store/settings_test.go @@ -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) } }