0.2.52
This commit is contained in:
@@ -1,3 +1,20 @@
|
|||||||
|
## 0.2.53 — 2026-08-11
|
||||||
|
- Improved: The play button used by the mini hero cards now appears over every focused poster.
|
||||||
|
- Improved: Reduced the primary hero logo so its supporting details remain visible.
|
||||||
|
- Improved: The profile rail now shows the name of the user signed in on the television.
|
||||||
|
- Improved: Renamed My Alerts to Notifications.
|
||||||
|
- Improved: Added notifications for every user when Sonarr reports that any show has been cancelled.
|
||||||
|
- Improved: Added a daily Sonarr lifecycle scan and status history so newly cancelled shows can be identified.
|
||||||
|
|
||||||
|
## 0.2.52 — 2026-08-11
|
||||||
|
- Fixed: The last refreshed date in the Devices list used the incorrect date format.
|
||||||
|
- Fixed: The show or film logo was missing from the primary home-page hero.
|
||||||
|
- Fixed: My Alerts did not include an alert when Memby had been updated.
|
||||||
|
- Fixed: The release date in version history used the incorrect date format.
|
||||||
|
- Improved: Added soundbar and passthrough settings to the on-screen display.
|
||||||
|
- Improved: Slimmed down the subtitles menu and made the Get subtitles button easier to see.
|
||||||
|
- Improved: Added country flags to the subtitles search and menu.
|
||||||
|
|
||||||
## 0.2.51 — 2026-08-11
|
## 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.
|
- 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.
|
- Fixed: Frequent screen updates can no longer repeat network requests, navigation, analytics, timers or other background work.
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ the alert that announced it has long since fallen out of its window. Things to p
|
|||||||
— unauthenticated on purpose, since a probe needing a token would report a stale session
|
— unauthenticated on purpose, since a probe needing a token would report a stale session
|
||||||
as a server outage.
|
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),
|
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.
|
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
|
`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 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 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/`.
|
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
|
**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.
|
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.51"
|
val defaultVersionName = "0.2.53"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -403,6 +403,10 @@ data class Settings(
|
|||||||
* not seen that build's update notice.
|
* not seen that build's update notice.
|
||||||
*/
|
*/
|
||||||
val whatsNewSeenVersion: String? = null,
|
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
|
* 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:
|
* 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 SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
||||||
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
|
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
|
||||||
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
|
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 REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
|
||||||
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
|
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
|
* once. Also written silently on a fresh install, which has not updated from an earlier
|
||||||
* build and therefore has nothing to announce.
|
* build and therefore has nothing to announce.
|
||||||
*/
|
*/
|
||||||
suspend fun markWhatsNewSeen(version: String) {
|
suspend fun markWhatsNewSeen(version: String, updateAlertAt: String? = null) {
|
||||||
val trimmed = version.trim()
|
val trimmed = version.trim()
|
||||||
if (trimmed.isEmpty()) return
|
if (trimmed.isEmpty()) return
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
preferences[Keys.WHATS_NEW_VERSION] = trimmed
|
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(),
|
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
|
||||||
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
|
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
|
||||||
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
|
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],
|
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
|
||||||
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
|
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
|
|||||||
@@ -115,6 +115,53 @@ fun normalizeSubtitleLanguage(raw: String?): String {
|
|||||||
return subtitleLanguageAliases[base] ?: base
|
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. */
|
/** The parts of a subtitle track [selectSubtitleId] needs, whatever produced it. */
|
||||||
data class SubtitleCandidate(
|
data class SubtitleCandidate(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
|||||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
// has one — see [TvNavigationRail]'s calendarEnabled.
|
||||||
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
|
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
|
||||||
FAVORITES("Favourites", Icons.Default.Favorite),
|
FAVORITES("Favourites", Icons.Default.Favorite),
|
||||||
PROFILES("Switch user", Icons.Default.Person),
|
PROFILES("User", Icons.Default.Person),
|
||||||
SETTINGS("Settings", Icons.Default.Settings),
|
SETTINGS("Settings", Icons.Default.Settings),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +361,11 @@ fun TvNavigationRail(
|
|||||||
destinations.forEach { destination ->
|
destinations.forEach { destination ->
|
||||||
ExpandableNavigationItem(
|
ExpandableNavigationItem(
|
||||||
destination = destination,
|
destination = destination,
|
||||||
|
label = if (destination == BrowseDestination.PROFILES) {
|
||||||
|
profileDestinationLabel(activeUsername)
|
||||||
|
} else {
|
||||||
|
destination.label
|
||||||
|
},
|
||||||
selected = destination == selected,
|
selected = destination == selected,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
modifier = if (destination == focusDestination) {
|
modifier = if (destination == focusDestination) {
|
||||||
@@ -370,7 +375,7 @@ fun TvNavigationRail(
|
|||||||
},
|
},
|
||||||
onFocused = {},
|
onFocused = {},
|
||||||
onClick = { onDestinationSelected(destination) },
|
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.
|
// here nothing on the launcher would ever say there was news waiting.
|
||||||
badge = if (destination == BrowseDestination.PROFILES) {
|
badge = if (destination == BrowseDestination.PROFILES) {
|
||||||
alertBadgeLabel(alertCount)
|
alertBadgeLabel(alertCount)
|
||||||
@@ -526,12 +531,12 @@ fun UserSwitcherOverlay(
|
|||||||
.background(Color.White.copy(alpha = 0.07f)),
|
.background(Color.White.copy(alpha = 0.07f)),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
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
|
// 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
|
// "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.
|
// what replaces the bell that used to sit in the corner of Home.
|
||||||
UserSwitcherAction(
|
UserSwitcherAction(
|
||||||
label = "My Alerts",
|
label = "Notifications",
|
||||||
icon = Icons.Default.Notifications,
|
icon = Icons.Default.Notifications,
|
||||||
badge = alertBadgeLabel(alertCount),
|
badge = alertBadgeLabel(alertCount),
|
||||||
modifier = Modifier
|
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
|
private const val UserSwitcherActionCount = 2
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -678,6 +683,7 @@ private val AlertBadgeRed = Color(0xFFE04747)
|
|||||||
@Composable
|
@Composable
|
||||||
fun ExpandableNavigationItem(
|
fun ExpandableNavigationItem(
|
||||||
destination: BrowseDestination,
|
destination: BrowseDestination,
|
||||||
|
label: String = destination.label,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onFocused: () -> Unit,
|
onFocused: () -> Unit,
|
||||||
@@ -719,8 +725,7 @@ fun ExpandableNavigationItem(
|
|||||||
.drawBehind { drawRect(background.value) }
|
.drawBehind { drawRect(background.value) }
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = badge?.let { "${destination.label}, $it waiting" }
|
contentDescription = badge?.let { "$label, $it waiting" } ?: label
|
||||||
?: destination.label
|
|
||||||
}
|
}
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -773,7 +778,7 @@ fun ExpandableNavigationItem(
|
|||||||
}
|
}
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
Text(
|
Text(
|
||||||
destination.label,
|
label,
|
||||||
color = foreground.value,
|
color = foreground.value,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
|
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
|
@Composable
|
||||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -2143,6 +2152,9 @@ private fun MediaCard(
|
|||||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (focused) {
|
||||||
|
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
item.name,
|
item.name,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.ponzischeme89.memby.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -30,7 +26,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Icon
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
@@ -345,6 +339,12 @@ private fun FeaturedMovieCard(
|
|||||||
previewArtwork: ImageBitmap?,
|
previewArtwork: ImageBitmap?,
|
||||||
) {
|
) {
|
||||||
val item = pick.item
|
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(
|
FocusScaleContainer(
|
||||||
onFocused = onFocused,
|
onFocused = onFocused,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@@ -403,16 +403,26 @@ private fun FeaturedMovieCard(
|
|||||||
// on the least specific thing on it — and it is the line that pushed a
|
// 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:
|
// 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.
|
// they have no fact line, and there the label is the only reason given.
|
||||||
Text(
|
if (useTextTitle) {
|
||||||
item.name,
|
Text(
|
||||||
color = Color.White,
|
item.name,
|
||||||
fontSize = 30.sp,
|
color = Color.White,
|
||||||
lineHeight = 32.sp,
|
fontSize = 30.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
lineHeight = 32.sp,
|
||||||
maxLines = 2,
|
fontWeight = FontWeight.Bold,
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = 2,
|
||||||
onTextLayout = { titleLines = it.lineCount },
|
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))
|
Spacer(Modifier.height(9.dp))
|
||||||
HeroFactLine(item)
|
HeroFactLine(item)
|
||||||
// The reason takes the synopsis's place rather than adding a line to
|
// The reason takes the synopsis's place rather than adding a line to
|
||||||
@@ -540,24 +550,11 @@ private fun MiniMovieCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (focused) {
|
if (focused) {
|
||||||
Box(
|
MembyArtworkPlayCue(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.padding(end = 16.dp)
|
.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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,26 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Calendar
|
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.
|
* 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
|
// Persist the receipt before producing the external effect. If Android
|
||||||
// recreates the activity immediately after the toast, the new composition
|
// recreates the activity immediately after the toast, the new composition
|
||||||
// must not announce the same build a second time.
|
// 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()
|
currentCoroutineContext().ensureActive()
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
@@ -1938,6 +1961,13 @@ private fun HomeScreen(
|
|||||||
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
|
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
|
||||||
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
|
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
|
||||||
var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
|
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) }
|
var showNotifications by remember { mutableStateOf(false) }
|
||||||
// Two different things: [launchingItem] is the gate that stops a second Play press
|
// 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
|
// 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(),
|
activeUsername = settings.username.orEmpty(),
|
||||||
calendarEnabled = tvCalendarEnabled,
|
calendarEnabled = tvCalendarEnabled,
|
||||||
)
|
)
|
||||||
@@ -2408,7 +2438,7 @@ private fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage
|
val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage
|
||||||
if (maintenanceMessage != null) {
|
if (maintenanceMessage != null) {
|
||||||
// The rows are gone but the rail is not: Settings and Switch user are
|
// The rows are gone but the rail is not: Settings and the active user are
|
||||||
// local, so there is no reason to strand the viewer here.
|
// local, so there is no reason to strand the viewer here.
|
||||||
MaintenanceScreen(
|
MaintenanceScreen(
|
||||||
message = maintenanceMessage,
|
message = maintenanceMessage,
|
||||||
@@ -3037,7 +3067,7 @@ private fun HomeScreen(
|
|||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
showProfiles = true
|
showProfiles = true
|
||||||
},
|
},
|
||||||
alertCount = notificationState.notifications.size,
|
alertCount = displayedNotifications.size,
|
||||||
onOpenAlerts = {
|
onOpenAlerts = {
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "notifications", action = "open", screen = journeyScreen,
|
category = "notifications", action = "open", screen = journeyScreen,
|
||||||
@@ -3086,7 +3116,7 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
BackHandler(onBack = closeSettings)
|
BackHandler(onBack = closeSettings)
|
||||||
// Settings is a destination, not a takeover: it leaves the main rail visible
|
// 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
|
// 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
|
// only reserves its collapsed footprint and takes the same expand shift the
|
||||||
// content area does, so an expanded rail slides Settings aside rather than
|
// content area does, so an expanded rail slides Settings aside rather than
|
||||||
@@ -3329,7 +3359,7 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
BackHandler(onBack = closeAlerts)
|
BackHandler(onBack = closeAlerts)
|
||||||
MyAlertsPage(
|
MyAlertsPage(
|
||||||
notifications = notificationState.notifications,
|
notifications = displayedNotifications,
|
||||||
preferences = notificationState.preferences,
|
preferences = notificationState.preferences,
|
||||||
loading = notificationsLoading,
|
loading = notificationsLoading,
|
||||||
errorMessage = notificationsError,
|
errorMessage = notificationsError,
|
||||||
@@ -3373,7 +3403,11 @@ private fun HomeScreen(
|
|||||||
// connection walking down the list and back up would send the same row's
|
// 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
|
// request once per pass — clearing the flag immediately is what makes the
|
||||||
// row stop asking.
|
// 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
|
val previousReadAt = notification.readAt
|
||||||
notificationState = notificationState.copy(
|
notificationState = notificationState.copy(
|
||||||
notifications = notificationState.notifications.map {
|
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
|
// 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
|
// page also re-aims where focus lands afterwards. A failure puts the row
|
||||||
// back where it was rather than quietly losing somebody's alert.
|
// back where it was rather than quietly losing somebody's alert.
|
||||||
onDismiss = { notification ->
|
onDismiss = onDismiss@{ notification ->
|
||||||
if (notificationsMutationBusy) return@MyAlertsPage
|
if (notificationsMutationBusy) return@onDismiss
|
||||||
|
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
|
||||||
|
scope.launch { ServiceLocator.settings.dismissUpdateAlert() }
|
||||||
|
return@onDismiss
|
||||||
|
}
|
||||||
notificationsMutationBusy = true
|
notificationsMutationBusy = true
|
||||||
val previous = notificationState
|
val previous = notificationState
|
||||||
notificationState = notificationState.copy(
|
notificationState = notificationState.copy(
|
||||||
@@ -3428,8 +3466,10 @@ private fun HomeScreen(
|
|||||||
notificationsMutationBusy = true
|
notificationsMutationBusy = true
|
||||||
val previous = notificationState
|
val previous = notificationState
|
||||||
val pending = notificationState.notifications.map(UserNotification::id)
|
val pending = notificationState.notifications.map(UserNotification::id)
|
||||||
|
val dismissLocalUpdate = settings.updateAlertVersion != null
|
||||||
notificationState = notificationState.copy(notifications = emptyList())
|
notificationState = notificationState.copy(notifications = emptyList())
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
|
||||||
val failed = pending.filter { id ->
|
val failed = pending.filter { id ->
|
||||||
runCatching { repo.dismissNotification(id) }.isFailure
|
runCatching { repo.dismissNotification(id) }.isFailure
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ private const val RETRY_SECONDS = 30
|
|||||||
/**
|
/**
|
||||||
* Fills the content area while the gateway reports a deliberate outage.
|
* 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
|
* 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
|
* unavailable. Everything here is cheap to draw — a handful of animated floats and plain
|
||||||
* Canvas geometry — because TV GPUs punish blur and layered transparency.
|
* Canvas geometry — because TV GPUs punish blur and layered transparency.
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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
|
@Composable
|
||||||
private fun PrimaryActionSurface(
|
private fun PrimaryActionSurface(
|
||||||
label: String,
|
label: String,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.ponzischeme89.memby.ui
|
|||||||
internal enum class UserSwitcherDirection { UP, DOWN }
|
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
|
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
|
||||||
* deterministic.
|
* deterministic.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ponzischeme89.memby.ui.alerts
|
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.
|
* 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
|
* 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 {
|
internal fun alertsSummary(total: Int, unread: Int): String = when {
|
||||||
total <= 0 -> "Nothing waiting for you"
|
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 -> {
|
else -> {
|
||||||
val alerts = if (total == 1) "1 alert" else "$total alerts"
|
val notifications = if (total == 1) "1 notification" else "$total notifications"
|
||||||
"$alerts · $unread new"
|
"$notifications · $unread new"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import com.ponzischeme89.memby.ui.theme.MembySurface
|
|||||||
import kotlinx.coroutines.delay
|
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.
|
* the user picker.
|
||||||
*
|
*
|
||||||
* It moved there because these alerts belong to a *person*, not to a television: they follow
|
* 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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
MembyChoiceChip(
|
MembyChoiceChip(
|
||||||
label = if (preferences.enabled) "Alerts on" else "Alerts off",
|
label = if (preferences.enabled) "Notifications on" else "Notifications off",
|
||||||
selected = preferences.enabled,
|
selected = preferences.enabled,
|
||||||
onClick = onToggleEnabled,
|
onClick = onToggleEnabled,
|
||||||
modifier = Modifier.focusRequester(actionsFocusRequester),
|
modifier = Modifier.focusRequester(actionsFocusRequester),
|
||||||
@@ -177,7 +177,7 @@ fun MyAlertsPage(
|
|||||||
Spacer(Modifier.height(18.dp))
|
Spacer(Modifier.height(18.dp))
|
||||||
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
|
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
|
||||||
if (loading && !hasAlerts) {
|
if (loading && !hasAlerts) {
|
||||||
AlertsNotice("Loading alerts…")
|
AlertsNotice("Loading notifications…")
|
||||||
} else if (errorMessage != null && !hasAlerts) {
|
} else if (errorMessage != null && !hasAlerts) {
|
||||||
AlertsNotice(errorMessage)
|
AlertsNotice(errorMessage)
|
||||||
} else if (!hasAlerts) {
|
} else if (!hasAlerts) {
|
||||||
@@ -232,7 +232,7 @@ private fun AlertsHeader(total: Int, unread: Int) {
|
|||||||
modifier = Modifier.padding(start = 16.dp),
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.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)
|
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,9 +248,9 @@ private fun AlertsEmptyState(enabled: Boolean) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
"Alerts about the shows you follow will show up here."
|
"Notifications about the shows you follow will show up here."
|
||||||
} else {
|
} else {
|
||||||
"Alerts are switched off, so nothing new will arrive here."
|
"Notifications are switched off, so nothing new will arrive here."
|
||||||
},
|
},
|
||||||
color = MembyQuietText,
|
color = MembyQuietText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
@@ -356,5 +356,6 @@ private fun AlertRow(
|
|||||||
private fun alertIcon(kind: String): ImageVector = when {
|
private fun alertIcon(kind: String): ImageVector = when {
|
||||||
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
|
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
|
||||||
kind.contains("series", 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
|
else -> Icons.Default.NotificationsActive
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,12 +76,16 @@ import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
|
|||||||
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
|
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
|
||||||
import com.ponzischeme89.memby.data.resolveCast
|
import com.ponzischeme89.memby.data.resolveCast
|
||||||
import com.ponzischeme89.memby.data.selectSubtitleId
|
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.EmbyPerson
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
|
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
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.MainActivity
|
||||||
import com.ponzischeme89.memby.ui.EmbyOutageBanner
|
import com.ponzischeme89.memby.ui.EmbyOutageBanner
|
||||||
import com.ponzischeme89.memby.ui.ServiceAlertBanner
|
import com.ponzischeme89.memby.ui.ServiceAlertBanner
|
||||||
@@ -104,6 +108,13 @@ import java.util.Locale
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
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
|
* Fullscreen Media3 player with native stream-track selection. Press Menu while
|
||||||
* playing to choose an audio or subtitle track; the subtitle controller button
|
* 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
|
// 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.
|
// 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 {
|
val options = buildList {
|
||||||
if (audio) add("Audio")
|
if (audio) add("Audio")
|
||||||
add("Subtitles & appearance")
|
add("Subtitles & appearance")
|
||||||
add("Picture size · ${selectedPictureMode().shortLabel}")
|
add(passthroughOption)
|
||||||
|
add(pictureOption)
|
||||||
}
|
}
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
|
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
|
||||||
@@ -3205,12 +3220,67 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
when (options[which]) {
|
when (options[which]) {
|
||||||
"Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
|
"Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
|
||||||
"Subtitles & appearance" -> showSubtitleOverlay()
|
"Subtitles & appearance" -> showSubtitleOverlay()
|
||||||
else -> showPictureModePicker()
|
passthroughOption -> showAudioPassthroughPicker()
|
||||||
|
pictureOption -> showPictureModePicker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showAudioPassthroughPicker() {
|
||||||
|
val current = ServiceLocator.settings.current.audioPassthroughPreference
|
||||||
|
val options = arrayOf("Auto detect", "Off", "Choose formats…")
|
||||||
|
val selected = when {
|
||||||
|
current.mode == AudioPassthroughMode.AUTO -> 0
|
||||||
|
current.codecs.isEmpty() -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Soundbar / passthrough")
|
||||||
|
.setSingleChoiceItems(options, selected) { dialog, which ->
|
||||||
|
when (which) {
|
||||||
|
0 -> saveAudioPassthrough(AudioPassthroughMode.AUTO, current.codecs)
|
||||||
|
1 -> saveAudioPassthrough(AudioPassthroughMode.MANUAL, emptySet())
|
||||||
|
else -> {
|
||||||
|
dialog.dismiss()
|
||||||
|
showManualPassthroughPicker(current.codecs)
|
||||||
|
return@setSingleChoiceItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showManualPassthroughPicker(initial: Set<SurroundCodec>) {
|
||||||
|
val selected = SurroundCodec.entries.map { it in initial }.toBooleanArray()
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Formats accepted by your soundbar")
|
||||||
|
.setMultiChoiceItems(
|
||||||
|
SurroundCodec.entries.map(SurroundCodec::label).toTypedArray(),
|
||||||
|
selected,
|
||||||
|
) { _, which, checked -> selected[which] = checked }
|
||||||
|
.setPositiveButton("Save") { _, _ ->
|
||||||
|
saveAudioPassthrough(
|
||||||
|
AudioPassthroughMode.MANUAL,
|
||||||
|
SurroundCodec.entries.filterIndexed { index, _ -> selected[index] }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioPassthrough(mode: AudioPassthroughMode, codecs: Set<SurroundCodec>) {
|
||||||
|
lifecycleScope.launch { ServiceLocator.settings.setAudioPassthrough(mode, codecs) }
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Soundbar setting saved. It applies when the next video starts.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
playerView?.hideController()
|
||||||
|
}
|
||||||
|
|
||||||
private fun showPictureModePicker() {
|
private fun showPictureModePicker() {
|
||||||
val selected = selectedPictureMode()
|
val selected = selectedPictureMode()
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
@@ -3483,7 +3553,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
discoveredTracks += TrackChoice(
|
discoveredTracks += TrackChoice(
|
||||||
group.mediaTrackGroup,
|
group.mediaTrackGroup,
|
||||||
index,
|
index,
|
||||||
trackLabel(group.mediaTrackGroup, index),
|
trackLabel(group.mediaTrackGroup, index, showSubtitleFlag = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3609,7 +3679,10 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
subtitleCandidates.forEach { candidate ->
|
subtitleCandidates.forEach { candidate ->
|
||||||
add(
|
add(
|
||||||
SubtitleMenuEntry(
|
SubtitleMenuEntry(
|
||||||
label = candidate.label.ifBlank { candidate.languageLabel },
|
label = subtitleLabelWithFlag(
|
||||||
|
candidate.language,
|
||||||
|
candidate.label.ifBlank { candidate.languageLabel },
|
||||||
|
),
|
||||||
selected = false,
|
selected = false,
|
||||||
enabled = !subtitleRequestInFlight,
|
enabled = !subtitleRequestInFlight,
|
||||||
),
|
),
|
||||||
@@ -3817,7 +3890,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
if (subtitle.isDefault) add("Default")
|
if (subtitle.isDefault) add("Default")
|
||||||
if (subtitle.isHearingImpaired) add("SDH")
|
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) {
|
private fun selectEncodedSubtitle(subtitle: PlayableSubtitle) {
|
||||||
@@ -3933,7 +4007,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@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 format = group.getFormat(index)
|
||||||
val rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() }
|
val rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val meaningfulLabel = rawLabel?.takeUnless { it.matches(Regex("""^\([^)]*\)$""")) }
|
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.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0) add("Default")
|
||||||
if (format.roleFlags and C.ROLE_FLAG_CAPTION != 0) add("CC")
|
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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.view.Gravity
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
@@ -39,7 +40,7 @@ data class SubtitleDownloadState(
|
|||||||
val entries: List<SubtitleMenuEntry> = emptyList(),
|
val entries: List<SubtitleMenuEntry> = emptyList(),
|
||||||
/**
|
/**
|
||||||
* Whether the panel has switched to being *about* downloading, which hides the track
|
* 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
|
* 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
|
* 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.
|
* at a time, and Back is what steps out of this one.
|
||||||
@@ -99,6 +100,9 @@ fun bindSubtitleMenu(
|
|||||||
tracks.forEachIndexed { index, entry ->
|
tracks.forEachIndexed { index, entry ->
|
||||||
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
|
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
|
||||||
}
|
}
|
||||||
|
overlay.findViewById<ScrollView>(R.id.player_subtitle_tracks_scroll)?.apply {
|
||||||
|
layoutParams = layoutParams.apply { height = menuRowsHeight(context, tracks.size, 3) }
|
||||||
|
}
|
||||||
sizes.forEachIndexed { index, entry ->
|
sizes.forEachIndexed { index, entry ->
|
||||||
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
|
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
|
||||||
}
|
}
|
||||||
@@ -128,6 +132,16 @@ private fun bindDownloadSection(
|
|||||||
downloads.entries.forEachIndexed { index, entry ->
|
downloads.entries.forEachIndexed { index, entry ->
|
||||||
container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) })
|
container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) })
|
||||||
}
|
}
|
||||||
|
overlay.findViewById<ScrollView>(R.id.player_subtitle_downloads_scroll)?.apply {
|
||||||
|
layoutParams = layoutParams.apply {
|
||||||
|
height = menuRowsHeight(overlay.context, downloads.entries.size, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun menuRowsHeight(context: Context, rows: Int, maximumRows: Int): Int {
|
||||||
|
val density = context.resources.displayMetrics.density
|
||||||
|
return (rows.coerceIn(1, maximumRows) * 42 * density).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,7 +159,7 @@ private fun subtitleMenuOption(
|
|||||||
fun dp(value: Int) = (value * density).toInt()
|
fun dp(value: Int) = (value * density).toInt()
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
|
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
dp(if (chip) 40 else 46),
|
dp(if (chip) 36 else 40),
|
||||||
).apply {
|
).apply {
|
||||||
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
|
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))
|
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
|
||||||
}
|
}
|
||||||
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
|
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
|
text = entry.label
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
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)
|
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
|
// 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.
|
// search in flight cannot be started twice and nothing moves while it runs.
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
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)
|
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")
|
if (device.current) add("This TV")
|
||||||
device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") }
|
device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") }
|
||||||
device.lastSeenAt.takeIf(String::isNotBlank)?.let {
|
device.lastSeenAt.takeIf(String::isNotBlank)?.let {
|
||||||
add("Last active ${it.replace('T', ' ').substringBefore('.').removeSuffix("Z")}")
|
add("Last active ${formatDeviceLastSeen(it)}")
|
||||||
}
|
}
|
||||||
}.joinToString(" • ").ifBlank { "Signed in" }
|
}.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
|
@Composable
|
||||||
private fun DeviceManagementRow(
|
private fun DeviceManagementRow(
|
||||||
device: GatewayDevice,
|
device: GatewayDevice,
|
||||||
@@ -1994,7 +2005,7 @@ private fun ReleaseHistoryRow(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
release.date.takeIf(String::isNotBlank)?.let {
|
release.date.takeIf(String::isNotBlank)?.let {
|
||||||
Text(it, color = TextQuiet, fontSize = 12.sp)
|
Text(formatReleaseDate(it), color = TextQuiet, fontSize = 12.sp)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
|
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ internal data class ReleaseNote(
|
|||||||
val changes: List<String>,
|
val changes: List<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val ReleaseMonths = listOf(
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Turns the changelog's wire-friendly ISO date into the day-first date shown in About. */
|
||||||
|
internal fun formatReleaseDate(raw: String): String {
|
||||||
|
val date = raw.trim()
|
||||||
|
if (date.length != 10 || date[4] != '-' || date[7] != '-') return raw
|
||||||
|
val year = date.substring(0, 4).toIntOrNull() ?: return raw
|
||||||
|
val month = date.substring(5, 7).toIntOrNull() ?: return raw
|
||||||
|
val day = date.substring(8, 10).toIntOrNull() ?: return raw
|
||||||
|
if (month !in 1..12 || day !in 1..31) return raw
|
||||||
|
return "$day ${ReleaseMonths[month - 1]} $year"
|
||||||
|
}
|
||||||
|
|
||||||
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
|
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
what caps how tall the track list can grow before it scrolls.
|
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
|
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.
|
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:
|
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. -->
|
the search row is the way in, Back is the way out, one press per level. -->
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/player_subtitle_menu"
|
android:id="@+id/player_subtitle_menu"
|
||||||
android:layout_width="344dp"
|
android:layout_width="316dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="bottom|end"
|
android:layout_gravity="bottom|end"
|
||||||
android:layout_marginTop="96dp"
|
android:layout_marginTop="96dp"
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
android:background="@drawable/player_menu_background"
|
android:background="@drawable/player_menu_background"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingStart="10dp"
|
android:paddingStart="10dp"
|
||||||
android:paddingTop="14dp"
|
android:paddingTop="10dp"
|
||||||
android:paddingEnd="10dp"
|
android:paddingEnd="10dp"
|
||||||
android:paddingBottom="12dp">
|
android:paddingBottom="10dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/player_subtitle_main_section"
|
android:id="@+id/player_subtitle_main_section"
|
||||||
@@ -67,10 +67,10 @@
|
|||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
android:id="@+id/player_subtitle_tracks_scroll"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="6dp"
|
||||||
android:layout_weight="1"
|
|
||||||
android:fillViewport="false"
|
android:fillViewport="false"
|
||||||
android:overScrollMode="never"
|
android:overScrollMode="never"
|
||||||
android:scrollbars="none">
|
android:scrollbars="none">
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1dp"
|
android:layout_height="1dp"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
android:layout_marginTop="10dp"
|
android:layout_marginTop="7dp"
|
||||||
android:layout_marginEnd="14dp"
|
android:layout_marginEnd="14dp"
|
||||||
android:background="#14FFFFFF" />
|
android:background="#14FFFFFF" />
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="8dp"
|
||||||
android:letterSpacing="0.12"
|
android:letterSpacing="0.12"
|
||||||
android:text="@string/player_text_size"
|
android:text="@string/player_text_size"
|
||||||
android:textColor="#7AFFFFFF"
|
android:textColor="#7AFFFFFF"
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
android:id="@+id/player_subtitle_sizes"
|
android:id="@+id/player_subtitle_sizes"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="6dp"
|
||||||
android:orientation="horizontal" />
|
android:orientation="horizontal" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -115,7 +115,6 @@
|
|||||||
android:id="@+id/player_subtitle_download_section"
|
android:id="@+id/player_subtitle_download_section"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1dp"
|
android:layout_height="1dp"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginEnd="14dp"
|
android:layout_marginEnd="14dp"
|
||||||
android:background="#14FFFFFF" />
|
android:background="#14FFFFFF" />
|
||||||
|
|
||||||
@@ -135,7 +134,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="8dp"
|
||||||
android:letterSpacing="0.12"
|
android:letterSpacing="0.12"
|
||||||
android:text="@string/player_subtitle_download"
|
android:text="@string/player_subtitle_download"
|
||||||
android:textColor="#7AFFFFFF"
|
android:textColor="#7AFFFFFF"
|
||||||
@@ -154,10 +153,10 @@
|
|||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
android:id="@+id/player_subtitle_downloads_scroll"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="6dp"
|
||||||
android:layout_weight="1"
|
|
||||||
android:fillViewport="false"
|
android:fillViewport="false"
|
||||||
android:overScrollMode="never"
|
android:overScrollMode="never"
|
||||||
android:scrollbars="none">
|
android:scrollbars="none">
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ class SubtitlePreferenceTest {
|
|||||||
assertEquals("klingon", normalizeSubtitleLanguage("Klingon"))
|
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
|
@Test
|
||||||
fun `subtitles turned off means nothing is selected`() {
|
fun `subtitles turned off means nothing is selected`() {
|
||||||
val tracks = listOf(track("1", "eng", default = true))
|
val tracks = listOf(track("1", "eng", default = true))
|
||||||
|
|||||||
@@ -16,4 +16,10 @@ class ProfileInitialsTest {
|
|||||||
assertEquals("AL", profileInitials("alwyn"))
|
assertEquals("AL", profileInitials("alwyn"))
|
||||||
assertEquals("?", profileInitials(" "))
|
assertEquals("?", profileInitials(" "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `profile destination names the signed in viewer`() {
|
||||||
|
assertEquals("Matt Cohen", profileDestinationLabel(" Matt Cohen "))
|
||||||
|
assertEquals("User", profileDestinationLabel(" "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class UserSwitcherNavigationTest {
|
|||||||
fun `both pinned actions are reachable below the profiles`() {
|
fun `both pinned actions are reachable below the profiles`() {
|
||||||
val profileCount = 3
|
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(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||||
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
|
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||||
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
|
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ class AlertsFormatTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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("Nothing waiting for you", alertsSummary(total = 0, unread = 0))
|
||||||
assertEquals("1 alert", alertsSummary(total = 1, unread = 0))
|
assertEquals("1 notification", alertsSummary(total = 1, unread = 0))
|
||||||
assertEquals("4 alerts", alertsSummary(total = 4, unread = 0))
|
assertEquals("4 notifications", alertsSummary(total = 4, unread = 0))
|
||||||
assertEquals("4 alerts · 2 new", alertsSummary(total = 4, unread = 2))
|
assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2))
|
||||||
assertEquals("1 alert · 1 new", alertsSummary(total = 1, unread = 1))
|
assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import org.robolectric.annotation.Config
|
|||||||
import org.robolectric.annotation.GraphicsMode
|
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.
|
* at without deploying to a TV.
|
||||||
*
|
*
|
||||||
* ```powershell
|
* ```powershell
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.player
|
||||||
|
|
||||||
|
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
|
||||||
|
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
||||||
|
import com.ponzischeme89.memby.data.playback.SurroundCodec
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AudioPassthroughOsdTest {
|
||||||
|
@Test
|
||||||
|
fun `summary distinguishes automatic off and manual formats`() {
|
||||||
|
assertEquals("Auto", passthroughOsdSummary(AudioPassthroughPreference.AUTOMATIC))
|
||||||
|
assertEquals(
|
||||||
|
"Off",
|
||||||
|
passthroughOsdSummary(AudioPassthroughPreference(AudioPassthroughMode.MANUAL)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Dolby Digital",
|
||||||
|
passthroughOsdSummary(
|
||||||
|
AudioPassthroughPreference(
|
||||||
|
AudioPassthroughMode.MANUAL,
|
||||||
|
setOf(SurroundCodec.AC3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"2 formats",
|
||||||
|
passthroughOsdSummary(
|
||||||
|
AudioPassthroughPreference(
|
||||||
|
AudioPassthroughMode.MANUAL,
|
||||||
|
setOf(SurroundCodec.AC3, SurroundCodec.DTS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,9 +41,9 @@ class SubtitleMenuScreenshotTest {
|
|||||||
name = "subtitles-menu-track-selected",
|
name = "subtitles-menu-track-selected",
|
||||||
tracks = listOf(
|
tracks = listOf(
|
||||||
entry("Off"),
|
entry("Off"),
|
||||||
entry("English", selected = true),
|
entry("🇬🇧 English", selected = true),
|
||||||
entry("English · SDH"),
|
entry("🇬🇧 English · SDH"),
|
||||||
entry("Italian · Forced"),
|
entry("🇮🇹 Italian · Forced"),
|
||||||
),
|
),
|
||||||
focused = 1,
|
focused = 1,
|
||||||
)
|
)
|
||||||
@@ -54,7 +54,7 @@ class SubtitleMenuScreenshotTest {
|
|||||||
fun `subtitles off`() {
|
fun `subtitles off`() {
|
||||||
capture(
|
capture(
|
||||||
name = "subtitles-menu-off",
|
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,
|
focused = 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ class SubtitleMenuScreenshotTest {
|
|||||||
fun `text size focused`() {
|
fun `text size focused`() {
|
||||||
capture(
|
capture(
|
||||||
name = "subtitles-menu-text-size",
|
name = "subtitles-menu-text-size",
|
||||||
tracks = listOf(entry("Off"), entry("English", selected = true)),
|
tracks = listOf(entry("Off"), entry("🇬🇧 English", selected = true)),
|
||||||
focusedSize = 2,
|
focusedSize = 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -79,14 +79,14 @@ class SubtitleMenuScreenshotTest {
|
|||||||
name = "subtitles-menu-long-list",
|
name = "subtitles-menu-long-list",
|
||||||
tracks = listOf(
|
tracks = listOf(
|
||||||
entry("Off"),
|
entry("Off"),
|
||||||
entry("English", selected = true),
|
entry("🇬🇧 English", selected = true),
|
||||||
entry("English · SDH"),
|
entry("🇬🇧 English · SDH"),
|
||||||
entry("Français"),
|
entry("🇫🇷 Français"),
|
||||||
entry("Deutsch · Forced"),
|
entry("🇩🇪 Deutsch · Forced"),
|
||||||
entry("Italiano"),
|
entry("🇮🇹 Italiano"),
|
||||||
entry("Español · Default"),
|
entry("🇪🇸 Español · Default"),
|
||||||
entry("Português (Brasil)"),
|
entry("🇧🇷 Português (Brasil)"),
|
||||||
entry("Nederlands · SDH · Burned in"),
|
entry("🇳🇱 Nederlands · SDH · Burned in"),
|
||||||
),
|
),
|
||||||
focused = 1,
|
focused = 1,
|
||||||
)
|
)
|
||||||
@@ -101,7 +101,7 @@ class SubtitleMenuScreenshotTest {
|
|||||||
fun `download offered with nothing suitable in the list`() {
|
fun `download offered with nothing suitable in the list`() {
|
||||||
capture(
|
capture(
|
||||||
name = "subtitles-menu-download-offered",
|
name = "subtitles-menu-download-offered",
|
||||||
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
|
tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
|
||||||
downloads = SubtitleDownloadState(
|
downloads = SubtitleDownloadState(
|
||||||
available = true,
|
available = true,
|
||||||
entries = listOf(entry("Search for subtitles…", prominent = true)),
|
entries = listOf(entry("Search for subtitles…", prominent = true)),
|
||||||
@@ -134,16 +134,16 @@ class SubtitleMenuScreenshotTest {
|
|||||||
fun `download results`() {
|
fun `download results`() {
|
||||||
capture(
|
capture(
|
||||||
name = "subtitles-menu-download-results",
|
name = "subtitles-menu-download-results",
|
||||||
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
|
tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
|
||||||
downloads = SubtitleDownloadState(
|
downloads = SubtitleDownloadState(
|
||||||
available = true,
|
available = true,
|
||||||
expanded = true,
|
expanded = true,
|
||||||
entries = listOf(
|
entries = listOf(
|
||||||
entry("Search again", prominent = true),
|
entry("Search again", prominent = true),
|
||||||
entry("English · 98% match"),
|
entry("🇬🇧 English · 98% match"),
|
||||||
entry("English · Hearing impaired · 96% match"),
|
entry("🇬🇧 English · Hearing impaired · 96% match"),
|
||||||
entry("English · Forced · 91% match"),
|
entry("🇬🇧 English · Forced · 91% match"),
|
||||||
entry("Italian · 84% match"),
|
entry("🇮🇹 Italian · 84% match"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedDownload = 1,
|
focusedDownload = 1,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.ui.settings
|
|||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
class VersionHistoryTest {
|
class VersionHistoryTest {
|
||||||
|
|
||||||
@@ -69,4 +70,19 @@ class VersionHistoryTest {
|
|||||||
releases.map { it.version },
|
releases.map { it.version },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `release dates are shown day first`() {
|
||||||
|
assertEquals("11 Aug 2026", formatReleaseDate("2026-08-11"))
|
||||||
|
assertEquals("unknown", formatReleaseDate("unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `device activity is shown in local day first format`() {
|
||||||
|
assertEquals(
|
||||||
|
"11 Aug 2026, 10:05 pm",
|
||||||
|
formatDeviceLastSeen("2026-08-11T10:05:00Z", ZoneId.of("Pacific/Auckland")),
|
||||||
|
)
|
||||||
|
assertEquals("not-a-date", formatDeviceLastSeen("not-a-date", ZoneId.of("UTC")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.ponzischeme89.memby.ui.whatsnew
|
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.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class WhatsNewTest {
|
class WhatsNewTest {
|
||||||
@@ -49,4 +53,14 @@ class WhatsNewTest {
|
|||||||
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
|
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an installed update becomes a local Notifications entry`() {
|
||||||
|
val alert = membyUpdateNotification("0.2.52", "2026-08-11T10:00:00Z", read = false)!!
|
||||||
|
assertEquals(MEMBY_UPDATE_NOTIFICATION_ID, alert.id)
|
||||||
|
assertEquals("Memby updated", alert.title)
|
||||||
|
assertEquals("This TV is now running Memby 0.2.52.", alert.message)
|
||||||
|
assertFalse(alert.readAt != null)
|
||||||
|
assertNull(membyUpdateNotification(" ", null, read = false))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-8
@@ -13,8 +13,8 @@ The remote deployment:
|
|||||||
- installs .env from the local .env.example, overwriting the deployed
|
- installs .env from the local .env.example, overwriting the deployed
|
||||||
copy, and keeps the old one alongside as .env.previous;
|
copy, and keeps the old one alongside as .env.previous;
|
||||||
- preserves the named database volume;
|
- preserves the named database volume;
|
||||||
- tells every signed-in television a deployment is starting, through the
|
- unless --Quiet is supplied, tells every signed-in television a deployment
|
||||||
gateway that is still running, before the build begins;
|
is starting through the gateway that is still running before the build begins;
|
||||||
- pulls Redis and PostgreSQL, then builds the Memby server;
|
- pulls Redis and PostgreSQL, then builds the Memby server;
|
||||||
- starts each service and waits for its health check;
|
- starts each service and waits for its health check;
|
||||||
- restores the previous application files if activation fails.
|
- restores the previous application files if activation fails.
|
||||||
@@ -38,6 +38,9 @@ This deploys the current local working tree, including uncommitted server change
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
|
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\deploy-server.ps1 --Quiet
|
||||||
#>
|
#>
|
||||||
|
|
||||||
#Requires -Version 7.2
|
#Requires -Version 7.2
|
||||||
@@ -82,16 +85,27 @@ param(
|
|||||||
[Alias('m')]
|
[Alias('m')]
|
||||||
[switch] $MandatoryUpdate,
|
[switch] $MandatoryUpdate,
|
||||||
|
|
||||||
# PowerShell advanced scripts do not bind GNU-style double-dash switches. Accept
|
[Parameter()]
|
||||||
# --m explicitly as the sole positional argument so it can still sit at the end.
|
[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)]
|
[Parameter(Position = 0)]
|
||||||
[ValidateSet('--m')]
|
[string] $TrailingFlag0,
|
||||||
[string] $MandatoryFlag
|
|
||||||
|
[Parameter(Position = 1)]
|
||||||
|
[string] $TrailingFlag1
|
||||||
)
|
)
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = 'Stop'
|
$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) {
|
if ($mandatoryRelease -and $SkipAppRelease) {
|
||||||
throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
|
throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
|
||||||
@@ -108,6 +122,9 @@ function Write-Banner {
|
|||||||
if ($mandatoryRelease) {
|
if ($mandatoryRelease) {
|
||||||
Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow
|
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 '╰─' -ForegroundColor Magenta
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
}
|
}
|
||||||
@@ -534,6 +551,7 @@ destination='__DESTINATION__'
|
|||||||
health_timeout=__HEALTH_TIMEOUT__
|
health_timeout=__HEALTH_TIMEOUT__
|
||||||
publish_release=__PUBLISH_RELEASE__
|
publish_release=__PUBLISH_RELEASE__
|
||||||
mandatory_update=__MANDATORY_UPDATE__
|
mandatory_update=__MANDATORY_UPDATE__
|
||||||
|
quiet_deployment=__QUIET_DEPLOYMENT__
|
||||||
parent=$(dirname "$destination")
|
parent=$(dirname "$destination")
|
||||||
staging="${destination}.new.$$"
|
staging="${destination}.new.$$"
|
||||||
backup="${destination}.previous.$$"
|
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
|
# 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.
|
# 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'
|
detail 'No previous release to announce from; skipping'
|
||||||
elif ! command -v curl >/dev/null 2>&1; then
|
elif ! command -v curl >/dev/null 2>&1; then
|
||||||
detail 'curl is unavailable on the NAS; skipping the announcement'
|
detail 'curl is unavailable on the NAS; skipping the announcement'
|
||||||
@@ -895,6 +915,10 @@ success 'Memby gateway: https://mserver.sublogue.com'
|
|||||||
'__MANDATORY_UPDATE__',
|
'__MANDATORY_UPDATE__',
|
||||||
$(if ($mandatoryRelease) { '1' } else { '0' })
|
$(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
|
# 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
|
# 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
|
# of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
|||||||
go server.WatchMaintenance(ctx, 30*time.Second)
|
go server.WatchMaintenance(ctx, 30*time.Second)
|
||||||
// One probe per gateway, not per TV: the answer is the same for the whole house.
|
// One probe per gateway, not per TV: the answer is the same for the whole house.
|
||||||
go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval)
|
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 {
|
if err := server.LoadUpdatePolicy(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ func (s *Server) adminRoutes() http.Handler {
|
|||||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||||
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
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/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
|
||||||
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
|
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
|
||||||
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
|
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
|
||||||
@@ -264,6 +266,7 @@ type adminStatus struct {
|
|||||||
ForYouRunning bool `json:"forYouRunning"`
|
ForYouRunning bool `json:"forYouRunning"`
|
||||||
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
||||||
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
||||||
|
HeroPolicy heroAdminPolicy `json:"heroPolicy"`
|
||||||
MDBList mdblistAdminSettings `json:"mdblist"`
|
MDBList mdblistAdminSettings `json:"mdblist"`
|
||||||
Subtitles subtitleAdminSettings `json:"subtitles"`
|
Subtitles subtitleAdminSettings `json:"subtitles"`
|
||||||
Features featureResponse `json:"features"`
|
Features featureResponse `json:"features"`
|
||||||
@@ -329,6 +332,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return policy
|
return policy
|
||||||
}(),
|
}(),
|
||||||
|
HeroPolicy: s.heroAdminPolicy(ctx),
|
||||||
Subtitles: s.subtitleAdminSettings(ctx),
|
Subtitles: s.subtitleAdminSettings(ctx),
|
||||||
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
||||||
RequestUsers: requestUsers,
|
RequestUsers: requestUsers,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2 class="card-title" data-icon="star" data-icon-tone="note">Pinned titles</h2>
|
||||||
|
<p class="card-note">Pinned films and series lead the four-card launcher grid in this order. Empty
|
||||||
|
places are filled by Memby’s existing mix of recent digital releases, premieres and
|
||||||
|
highly rated library titles. Pinning changes placement only; labels and reasons remain natural.</p>
|
||||||
|
</div>
|
||||||
|
<div id="hero-pins"></div>
|
||||||
|
<label class="field"><span>Prime-card subtitle</span>
|
||||||
|
<em>Optional wording under the large first card. Leave blank to use Memby’s natural release or rating reason.</em>
|
||||||
|
<input id="hero-prime-subtitle" type="text" maxlength="160"
|
||||||
|
placeholder="Leave blank for the automatic reason"></label>
|
||||||
|
<div class="card-foot">
|
||||||
|
<button class="primary" id="hero-save">Save hero</button>
|
||||||
|
<button id="hero-clear">Clear pins</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2 class="card-title" data-icon="search" data-icon-tone="info">Find a title</h2>
|
||||||
|
<p class="card-note">Search the imported Emby catalogue. Up to four films or series can be pinned.</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label class="field"><span>Title</span>
|
||||||
|
<input id="hero-query" type="search" placeholder="Search films and television shows"></label>
|
||||||
|
<button id="hero-search">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid" id="hero-results"></div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const { fmt, ui, $ } = Admin;
|
||||||
|
let pins = [];
|
||||||
|
let pinsDirty = false;
|
||||||
|
|
||||||
|
function renderPins() {
|
||||||
|
$('hero-pins').innerHTML = pins.length
|
||||||
|
? '<div class="chips">' + pins.map((item, index) =>
|
||||||
|
'<button class="quiet small" data-remove="' + fmt.escape(item.id) + '">' +
|
||||||
|
(index + 1) + '. ' + fmt.escape(item.name) + (item.year ? ' (' + item.year + ')' : '') +
|
||||||
|
' · remove</button>').join('') + '</div>'
|
||||||
|
: ui.empty('No titles are pinned. The hero is entirely release-aware and automatic.');
|
||||||
|
for (const button of $('hero-pins').querySelectorAll('[data-remove]')) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
pins = pins.filter((item) => item.id !== button.dataset.remove);
|
||||||
|
pinsDirty = true;
|
||||||
|
renderPins();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Admin.onStatus((status) => {
|
||||||
|
if (pinsDirty || !Admin.settled($('hero-pins'))) return;
|
||||||
|
pins = (status.heroPolicy?.pinnedItems || []).slice(0, 4);
|
||||||
|
Admin.fill($('hero-prime-subtitle'), status.heroPolicy?.primeSubtitle || '');
|
||||||
|
renderPins();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const query = $('hero-query').value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
const payload = await Admin.api('/admin/api/hero/search?q=' + encodeURIComponent(query));
|
||||||
|
const items = payload.items || [];
|
||||||
|
$('hero-results').innerHTML = items.length ? items.map((item) =>
|
||||||
|
'<section class="card"><h2 class="card-title">' + fmt.escape(item.name) + '</h2>' +
|
||||||
|
'<p class="card-note">' + fmt.escape(item.type || 'Title') + ' · ' +
|
||||||
|
(item.year || 'Year unknown') + '</p>' +
|
||||||
|
'<button data-add="' + fmt.escape(item.id) + '">Add to hero</button></section>').join('')
|
||||||
|
: ui.empty('No playable films or series matched that search.');
|
||||||
|
for (const button of $('hero-results').querySelectorAll('[data-add]')) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const selected = items.find((item) => item.id === button.dataset.add);
|
||||||
|
if (!selected || pins.some((item) => item.id === selected.id)) return;
|
||||||
|
if (pins.length >= 4) {
|
||||||
|
Admin.error('Remove a pinned film before adding another.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pins.push(selected);
|
||||||
|
pinsDirty = true;
|
||||||
|
renderPins();
|
||||||
|
Admin.error('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Admin.ready(() => {
|
||||||
|
$('hero-search').addEventListener('click', () => Admin.act(search));
|
||||||
|
$('hero-query').addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') Admin.act(search);
|
||||||
|
});
|
||||||
|
$('hero-save').addEventListener('click', () => Admin.act(async () => {
|
||||||
|
await Admin.api('/admin/api/hero-policy', {
|
||||||
|
method: 'POST', body: JSON.stringify({
|
||||||
|
pinnedItemIds: pins.map((item) => item.id),
|
||||||
|
primeSubtitle: $('hero-prime-subtitle').value.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
pinsDirty = false;
|
||||||
|
}));
|
||||||
|
$('hero-clear').addEventListener('click', () => {
|
||||||
|
pins = [];
|
||||||
|
pinsDirty = true;
|
||||||
|
renderPins();
|
||||||
|
});
|
||||||
|
$('hero-prime-subtitle').addEventListener('input', () => { pinsDirty = true; });
|
||||||
|
});
|
||||||
@@ -112,6 +112,11 @@ var adminNav = []adminNavGroup{
|
|||||||
{
|
{
|
||||||
Label: "Experience",
|
Label: "Experience",
|
||||||
Items: []adminNavItem{
|
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",
|
ID: "features", Label: "Features", Title: "Features",
|
||||||
Intro: "Roll out, stop and recover optional behaviour with no app release.",
|
Intro: "Roll out, stop and recover optional behaviour with no app release.",
|
||||||
|
|||||||
+100
-3
@@ -57,6 +57,7 @@ import (
|
|||||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -134,6 +135,7 @@ type heroKind int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
heroMovie heroKind = iota
|
heroMovie heroKind = iota
|
||||||
|
heroSeries
|
||||||
heroSeriesPremiere
|
heroSeriesPremiere
|
||||||
heroSeasonPremiere
|
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)
|
return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
|
||||||
case candidate.Kind == heroSeriesPremiere:
|
case candidate.Kind == heroSeriesPremiere:
|
||||||
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
|
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:
|
case candidate.Kind == heroSeasonPremiere:
|
||||||
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
|
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
|
||||||
case fresh && acclaimed && candidate.Estimated:
|
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
|
// 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
|
// precise than the evidence: a digital release date carries no time of day, so a card
|
||||||
// never claims an hour.
|
// never claims an hour.
|
||||||
@@ -648,23 +665,30 @@ func (s *Server) heroRow(
|
|||||||
location = time.Local
|
location = time.Local
|
||||||
}
|
}
|
||||||
candidates := s.heroCandidates(ctx, rows, now)
|
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
|
// 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.
|
// what made the hero the same four cards for a week — see rotateHeroCandidates.
|
||||||
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
|
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
|
||||||
ranked := rotateHeroCandidates(
|
organic := rotateHeroCandidates(
|
||||||
pool,
|
pool,
|
||||||
heroVariationSeed(userID, heroRotationSlot(now, location)),
|
heroVariationSeed(userID, heroRotationSlot(now, location)),
|
||||||
heroRowLimit,
|
heroRowLimit,
|
||||||
)
|
)
|
||||||
|
ranked := mergePinnedHeroCandidates(pinned, candidates, organic, heroRowLimit)
|
||||||
if len(ranked) == 0 {
|
if len(ranked) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
items := make([]json.RawMessage, 0, len(ranked))
|
items := make([]json.RawMessage, 0, len(ranked))
|
||||||
for _, candidate := range ranked {
|
for index, candidate := range ranked {
|
||||||
items = append(items, injectHeroFields(
|
items = append(items, injectHeroFields(
|
||||||
candidate.Item,
|
candidate.Item,
|
||||||
heroLabel(candidate, now),
|
heroLabel(candidate, now),
|
||||||
heroReason(candidate, now, location),
|
heroReasonForPosition(candidate, index, policy.PrimeSubtitle, now, location),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return &recommend.Row{
|
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.
|
// heroCandidates gathers everything eligible, premieres first.
|
||||||
//
|
//
|
||||||
// Premieres lead the input order so that they win a tie against a film of identical
|
// Premieres lead the input order so that they win a tie against a film of identical
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type heroAdminItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type heroAdminPolicy struct {
|
||||||
|
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
||||||
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) {
|
||||||
|
fact, ok := heroFactsOf(raw)
|
||||||
|
if !ok || !fact.Playable ||
|
||||||
|
(!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) {
|
||||||
|
return heroAdminItem{}, false
|
||||||
|
}
|
||||||
|
return heroAdminItem{ID: fact.ID, Name: fact.Name, Type: fact.Type, Year: heroYearOf(fact)}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
||||||
|
policy, err := s.store.HeroPolicy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("hero policy unavailable to admin", "error", err)
|
||||||
|
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
|
||||||
|
}
|
||||||
|
items, err := s.store.LibraryItemsByID(ctx, policy.PinnedItemIDs)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err)
|
||||||
|
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
|
||||||
|
}
|
||||||
|
byID := make(map[string]heroAdminItem, len(items))
|
||||||
|
for _, raw := range items {
|
||||||
|
if item, ok := adminHeroItem(raw); ok {
|
||||||
|
byID[item.ID] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := heroAdminPolicy{
|
||||||
|
PinnedItems: make([]heroAdminItem, 0, len(policy.PinnedItemIDs)),
|
||||||
|
PrimeSubtitle: policy.PrimeSubtitle,
|
||||||
|
}
|
||||||
|
for _, id := range policy.PinnedItemIDs {
|
||||||
|
if item, ok := byID[id]; ok {
|
||||||
|
out.PinnedItems = append(out.PinnedItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not search the library")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := make([]heroAdminItem, 0, len(items))
|
||||||
|
for _, raw := range items {
|
||||||
|
if item, ok := adminHeroItem(raw); ok {
|
||||||
|
results = append(results, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||||
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid hero policy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := uniqueHeroIDs(request.PinnedItemIDs)
|
||||||
|
if len(ids) > 4 {
|
||||||
|
writeError(w, http.StatusBadRequest, "the hero can pin at most four titles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := s.store.LibraryItemsByID(r.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(r.Context()).Error("hero title validation failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not validate hero titles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
valid := map[string]bool{}
|
||||||
|
for _, raw := range items {
|
||||||
|
if item, ok := adminHeroItem(raw); ok {
|
||||||
|
valid[item.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if !valid[id] {
|
||||||
|
writeError(w, http.StatusBadRequest, "every pinned item must be a playable library film or series")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle}
|
||||||
|
if err := s.store.SetHeroPolicy(r.Context(), policy); err != nil {
|
||||||
|
s.loggerFor(r.Context()).Error("hero policy write failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not save hero policy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invalidateAllHomeCaches(r.Context())
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueHeroIDs(ids []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) invalidateAllHomeCaches(ctx context.Context) {
|
||||||
|
users, err := s.store.KnownUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("hero cache invalidation could not list users", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if err := s.cache.InvalidateUser(ctx, user.ID); err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("hero cache invalidation failed", "user", user.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -537,6 +537,78 @@ func TestHeroRotationSlotIsLocal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPinnedHeroMoviesLeadAndOrganicSelectionFillsTheGrid(t *testing.T) {
|
||||||
|
pinned := []heroCandidate{
|
||||||
|
{ID: "custom-2", Item: json.RawMessage(`{"Id":"custom-2"}`)},
|
||||||
|
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`)},
|
||||||
|
}
|
||||||
|
organic := []heroCandidate{
|
||||||
|
// A pinned title can also qualify organically; it must still appear only once.
|
||||||
|
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`), Kind: heroSeasonPremiere},
|
||||||
|
{ID: "release-1", Item: json.RawMessage(`{"Id":"release-1"}`)},
|
||||||
|
{ID: "release-2", Item: json.RawMessage(`{"Id":"release-2"}`)},
|
||||||
|
{ID: "library-1", Item: json.RawMessage(`{"Id":"library-1"}`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := mergePinnedHeroCandidates(pinned, organic, organic[1:], 4)
|
||||||
|
want := []string{"custom-2", "custom-1", "release-1", "release-2"}
|
||||||
|
if strings.Join(heroIDs(got), ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("pinned hero order = %v, want %v", heroIDs(got), want)
|
||||||
|
}
|
||||||
|
if got[1].Kind != heroSeasonPremiere {
|
||||||
|
t.Fatalf("pin lost its natural premiere evidence: %+v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinnedHeroMergeHonoursAnEmptyOrShortGrid(t *testing.T) {
|
||||||
|
candidate := heroCandidate{ID: "custom", Item: json.RawMessage(`{"Id":"custom"}`)}
|
||||||
|
if got := mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 0); got != nil {
|
||||||
|
t.Fatalf("zero-sized grid = %v, want nil", heroIDs(got))
|
||||||
|
}
|
||||||
|
if got := heroIDs(mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 4)); strings.Join(got, ",") != "custom" {
|
||||||
|
t.Fatalf("short grid = %v, want custom", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrimeSubtitleChangesOnlyTheLargeFirstCard(t *testing.T) {
|
||||||
|
candidate := heroCandidate{ReleasedAt: heroDaysAgo(2), Kind: heroMovie}
|
||||||
|
if got := heroReasonForPosition(candidate, 0, " Family pick tonight ", heroNow, time.UTC); got != "Family pick tonight" {
|
||||||
|
t.Fatalf("prime subtitle = %q", got)
|
||||||
|
}
|
||||||
|
if got := heroReasonForPosition(candidate, 1, "Family pick tonight", heroNow, time.UTC); got == "Family pick tonight" || !strings.Contains(got, "Released") {
|
||||||
|
t.Fatalf("secondary card lost its natural reason: %q", got)
|
||||||
|
}
|
||||||
|
if got := heroReasonForPosition(candidate, 0, "", heroNow, time.UTC); !strings.Contains(got, "Released") {
|
||||||
|
t.Fatalf("blank override did not fall back to the natural reason: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinnedSeriesUsesTheSameNaturalPresentationAsOrganicTitles(t *testing.T) {
|
||||||
|
candidate := heroCandidate{Kind: heroSeries, ReleasedAt: heroDaysAgo(3)}
|
||||||
|
if got := heroLabel(candidate, heroNow); got != heroLabelNewRelease {
|
||||||
|
t.Fatalf("recent series label = %q, want %q", got, heroLabelNewRelease)
|
||||||
|
}
|
||||||
|
if got := heroReason(candidate, heroNow, time.UTC); !strings.Contains(got, "new series premiered") {
|
||||||
|
t.Fatalf("recent series reason = %q", got)
|
||||||
|
}
|
||||||
|
candidate.ReleasedAt = heroDaysAgo(100)
|
||||||
|
candidate.Rated, candidate.Rating = true, 0.9
|
||||||
|
if got := heroLabel(candidate, heroNow); got != heroLabelAcclaimed {
|
||||||
|
t.Fatalf("older acclaimed series label = %q, want %q", got, heroLabelAcclaimed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminHeroSearchAcceptsFilmsAndSeriesOnly(t *testing.T) {
|
||||||
|
for _, itemType := range []string{"Movie", "Series"} {
|
||||||
|
if item, ok := adminHeroItem(heroItem("id", "Title", itemType)); !ok || item.Type != itemType {
|
||||||
|
t.Fatalf("%s was not accepted: %+v, %t", itemType, item, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := adminHeroItem(heroItem("episode", "Episode", "Episode")); ok {
|
||||||
|
t.Fatal("episode was accepted as a pinnable hero title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func heroIDs(candidates []heroCandidate) []string {
|
func heroIDs(candidates []heroCandidate) []string {
|
||||||
ids := make([]string, 0, len(candidates))
|
ids := make([]string, 0, len(candidates))
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WatchSonarrLifecycle seeds the durable status history on startup, then refreshes it
|
||||||
|
// daily. History stores changes rather than identical daily snapshots: it still records
|
||||||
|
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
|
||||||
|
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
|
||||||
|
if s.sonarr == nil || interval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scan := func() {
|
||||||
|
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scan()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
scan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||||
|
series, err := s.sonarr.Series(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read Sonarr series: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
observations := make([]store.SonarrSeriesStatus, 0, len(series))
|
||||||
|
for _, item := range series {
|
||||||
|
key := sonarrSeriesStatusKey(item)
|
||||||
|
if key == "" || strings.TrimSpace(item.Status) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
observations = append(observations, store.SonarrSeriesStatus{
|
||||||
|
SeriesKey: key, SonarrSeriesID: item.ID, TVDBID: item.TVDBID,
|
||||||
|
Title: item.Title, Year: item.Year, Status: item.Status, ObservedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
|
||||||
|
for _, change := range changes {
|
||||||
|
if sonarrBecameCancelled(change.PreviousStatus, change.Current.Status) {
|
||||||
|
cancellations = append(cancellations, change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cancellations) == 0 {
|
||||||
|
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := s.store.KnownUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
preferences := map[string]store.NotificationPreferences{}
|
||||||
|
preferenceErrors := map[string]bool{}
|
||||||
|
notifications := 0
|
||||||
|
for _, change := range cancellations {
|
||||||
|
for _, user := range users {
|
||||||
|
prefs, ok := preferences[user.ID]
|
||||||
|
if !ok && !preferenceErrors[user.ID] {
|
||||||
|
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
preferenceErrors[user.ID] = true
|
||||||
|
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
|
||||||
|
"user", user.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
preferences[user.ID] = prefs
|
||||||
|
}
|
||||||
|
if preferenceErrors[user.ID] || !prefs.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eventAt := change.Current.ObservedAt
|
||||||
|
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||||
|
message := change.Current.Title + " is now listed as cancelled in Sonarr."
|
||||||
|
if err := s.store.UpsertNotification(
|
||||||
|
ctx, user.ID, sourceKey, "show-cancelled", "",
|
||||||
|
"Show cancelled", message, &eventAt,
|
||||||
|
); err != nil {
|
||||||
|
s.log.Warn("Sonarr cancellation notification failed",
|
||||||
|
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
notifications++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.log.Info("Sonarr lifecycle scan complete",
|
||||||
|
"series", len(observations), "changes", len(changes),
|
||||||
|
"cancelled", len(cancellations), "notifications", notifications)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sonarrSeriesStatusKey(series sonarr.Series) string {
|
||||||
|
if series.TVDBID > 0 {
|
||||||
|
return "tvdb:" + strconv.Itoa(series.TVDBID)
|
||||||
|
}
|
||||||
|
if series.ID > 0 {
|
||||||
|
return "sonarr:" + strconv.Itoa(series.ID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sonarrBecameCancelled(previous, current string) bool {
|
||||||
|
previous = strings.ToLower(strings.TrimSpace(previous))
|
||||||
|
current = strings.ToLower(strings.TrimSpace(current))
|
||||||
|
active := previous == "continuing" || previous == "upcoming"
|
||||||
|
cancelled := current == "ended" || current == "deleted" ||
|
||||||
|
current == "cancelled" || current == "canceled"
|
||||||
|
return active && cancelled
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSonarrSeriesStatusKeyPrefersTVDBIdentity(t *testing.T) {
|
||||||
|
series := sonarr.Series{ID: 42, TVDBID: 1234}
|
||||||
|
if got := sonarrSeriesStatusKey(series); got != "tvdb:1234" {
|
||||||
|
t.Fatalf("status key = %q, want tvdb:1234", got)
|
||||||
|
}
|
||||||
|
series.TVDBID = 0
|
||||||
|
if got := sonarrSeriesStatusKey(series); got != "sonarr:42" {
|
||||||
|
t.Fatalf("fallback status key = %q, want sonarr:42", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSonarrCancellationRequiresAnActiveHistory(t *testing.T) {
|
||||||
|
for _, transition := range [][2]string{
|
||||||
|
{"continuing", "ended"},
|
||||||
|
{"upcoming", "deleted"},
|
||||||
|
{"Continuing", "cancelled"},
|
||||||
|
} {
|
||||||
|
if !sonarrBecameCancelled(transition[0], transition[1]) {
|
||||||
|
t.Errorf("%q -> %q should be a cancellation", transition[0], transition[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, transition := range [][2]string{
|
||||||
|
{"", "ended"}, // first scan is a baseline, not news
|
||||||
|
{"ended", "ended"}, // an unchanged cancelled show is not announced daily
|
||||||
|
{"ended", "continuing"},
|
||||||
|
{"continuing", "continuing"},
|
||||||
|
} {
|
||||||
|
if sonarrBecameCancelled(transition[0], transition[1]) {
|
||||||
|
t.Errorf("%q -> %q should not be a cancellation", transition[0], transition[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,24 @@ type UserNotification struct {
|
|||||||
ReadAt *time.Time `json:"readAt,omitempty"`
|
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 {
|
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
|
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()
|
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) {
|
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
|
||||||
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
|
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
|||||||
@@ -239,6 +239,23 @@ CREATE TABLE IF NOT EXISTS user_notifications (
|
|||||||
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
|
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
|
||||||
ON user_notifications (emby_user_id, created_at DESC);
|
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
|
-- 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.
|
-- 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.
|
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
|
||||||
|
|||||||
@@ -18,14 +18,84 @@ const MaintenanceKey = "maintenance"
|
|||||||
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
|
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
|
||||||
const RequestPolicyKey = "request_policy"
|
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.
|
// shipping a new TV build.
|
||||||
const PlaybackPolicyKey = "playback_policy"
|
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
|
// 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.
|
// in this server-owned document and is never included in client or admin status payloads.
|
||||||
const MDBListSettingsKey = "mdblist_settings"
|
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{
|
var defaultMDBListSources = []string{
|
||||||
"imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert",
|
"imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert",
|
||||||
"tmdb", "trakt", "mal", "anilist", "anidb", "kitsu", "score", "score_average",
|
"tmdb", "trakt", "mal", "anilist", "anidb", "kitsu", "score", "score_average",
|
||||||
|
|||||||
@@ -25,6 +25,31 @@ func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHeroPolicyKeepsFourUniqueTrimmedItemIDsInOrder(t *testing.T) {
|
||||||
|
got := normalizeHeroPolicy(HeroPolicy{PinnedItemIDs: []string{
|
||||||
|
" movie-2 ", "movie-1", "movie-2", "", "movie-3", "movie-4", "movie-5",
|
||||||
|
}, PrimeSubtitle: " Tonight’s pick "})
|
||||||
|
want := []string{"movie-2", "movie-1", "movie-3", "movie-4"}
|
||||||
|
if len(got.PinnedItemIDs) != len(want) {
|
||||||
|
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
|
||||||
|
}
|
||||||
|
for index := range want {
|
||||||
|
if got.PinnedItemIDs[index] != want[index] {
|
||||||
|
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got.PrimeSubtitle != "Tonight’s pick" {
|
||||||
|
t.Fatalf("prime subtitle = %q", got.PrimeSubtitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
|
||||||
|
got := normalizeHeroPolicy(HeroPolicy{LegacyPinnedMovieIDs: []string{"movie-1"}})
|
||||||
|
if len(got.PinnedItemIDs) != 1 || got.PinnedItemIDs[0] != "movie-1" {
|
||||||
|
t.Fatalf("legacy pinned ids = %v", got.PinnedItemIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
||||||
defaults := DefaultMDBListSettings()
|
defaults := DefaultMDBListSettings()
|
||||||
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
|
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 ||
|
if got.APIKey != "secret" || len(got.Sources) != 2 ||
|
||||||
got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" {
|
got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" {
|
||||||
t.Fatalf("normalized MDBList settings = %+v", got)
|
t.Fatalf("normalised MDBList settings = %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user