This commit is contained in:
ponzischeme89
2026-08-23 13:20:54 +12:00
parent 766cea2199
commit 89ebe21201
35 changed files with 1000 additions and 195 deletions
+7 -5
View File
@@ -3379,11 +3379,13 @@ Things to preserve:
or a completion reminder can use the first without inheriting the second. Near dates are
named and far ones rounded to weeks or months — a pace measured over a fortnight cannot
honestly pick a day four months out.
- **"Catch up" is not a synonym for "finish".** `BaseItem.isOngoingSeries` prefers Sonarr's
lifecycle where the gateway attached one and falls back to Emby's `Status`, which is the
only source the direct path has; both absent means "finish", the weaker claim. `Status`
is in `fieldsDetail` on both paths for this, and the gateway's item cache key moved to
`item:v5:` so entries written before it cannot hide the field.
- **"Catch up" is not a synonym for "finish".** `Status` is the canonical series-detail
field on both paths. The gateway replaces Emby's value with the latest recognised Sonarr
observation stored by the existing daily lifecycle scan, while direct mode keeps Emby as
its only source; both absent means "finish", the weaker claim. The scan history's maximum
id versions the gateway item cache (`item:v7:r<revision>`) and rides `/v1/status`, so both
Redis and an open television's in-memory detail cache move when Sonarr does. Schedule-card
lifecycle metadata is only the opening-frame fallback before the full record arrives.
- **Quiet is the property a unit test cannot check**, so `SeriesPaceScreenshotTest` renders
the line on a real hero → `build/screenshots/series-pace/`. It covers both spacing cases
(with a progress bar above it and without), both verbs, and — the one worth keeping — the
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-C6xrkGsh.js"></script>
<script type="module" crossorigin src="/admin/assets/index-D2yWw-VA.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
</head>
+2
View File
@@ -40,6 +40,7 @@ import { SearchesPage } from './pages/Searches';
import { ViewsPage } from './pages/Views';
import { MediaReportsPage } from './pages/MediaReports';
import { NotificationsPage } from './pages/Notifications';
import { NotificationSettingsPage } from './pages/NotificationSettings';
import { CreditsPage } from './pages/Credits';
/* The console's routing table.
@@ -109,6 +110,7 @@ export function App() {
<Route path="searches" element={<SearchesPage />} />
<Route path="media-reports" element={<MediaReportsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="notification-settings" element={<NotificationSettingsPage />} />
{/* The old console redirected /admin/ to /admin/overview. Anything that
still links there lands on the overview rather than on a 404. */}
+16
View File
@@ -797,6 +797,22 @@ export interface GatewaySettingsResponse {
version: string;
}
/* ---------- television notifications ---------- */
/** NotificationPreferences is the per-person policy the gateway applies before anything
* reaches My Alerts or an informational banner on a television. */
export interface NotificationPreferences {
enabled: boolean;
showReturnAlerts: boolean;
sonarrAlerts: boolean;
radarrAlerts: boolean;
updateAlerts: boolean;
libraryAlerts: boolean;
systemAlerts: boolean;
watchTimeDigest: boolean;
leadDays: number;
}
/* ---------- metadata hero ---------- */
export interface MetadataHeroOption {
+8
View File
@@ -87,6 +87,14 @@ export const nav: NavGroup[] = [
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
icon: 'send',
},
{
id: 'notification-settings',
path: '/admin/notification-settings',
label: 'TV notifications',
title: 'TV notifications',
intro: 'Choose where notifications appear and who receives them.',
icon: 'bell',
},
{
id: 'media-reports',
path: '/admin/media-reports',
+1 -13
View File
@@ -19,7 +19,7 @@ import {
Tiles,
Toggle,
} from '../components/ui';
import type { DeviceVersion } from '../api/types';
import type { DeviceVersion, NotificationPreferences } from '../api/types';
/* One person: their televisions, their recommendation setup and the settings that follow
them to every set. The identity is in the URL rather than in a query string so the page
@@ -95,18 +95,6 @@ interface RecommendationState {
contentTypes?: string[];
}
interface NotificationPreferences {
enabled: boolean;
showReturnAlerts: boolean;
sonarrAlerts: boolean;
radarrAlerts: boolean;
updateAlerts: boolean;
libraryAlerts: boolean;
systemAlerts: boolean;
watchTimeDigest: boolean;
leadDays: number;
}
/* Tracearr's reading of this person. `matched` separates "no Tracearr, or nobody by this
name in it" from "has watched nothing", which are the same row of zeroes on the wire and
very different things for an operator to be told. */
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type {
GatewaySettingsResponse,
NotificationPreferences,
} from '../api/types';
import { Banner, Button, Card, Grid, Loading, PageHead, Tag, Toggle } from '../components/ui';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
interface NotificationAccount {
id: string;
username: string;
shortName?: string;
notifications: NotificationPreferences;
}
interface AccountsResponse {
accounts: NotificationAccount[] | null;
}
const displayChoices = [
{
value: 'everywhere',
label: 'Everywhere',
description: 'Show informational banners over the home screen and active playback.',
},
{
value: 'home_only',
label: 'Home only',
description: 'Show banners on the launcher, without interrupting a film or programme.',
},
{
value: 'off',
label: 'Off',
description: 'Do not show informational banners on any television.',
},
];
/** A dedicated control surface for what televisions show. The notification history keeps
* answering what happened; this page answers what is allowed to happen next. */
export function NotificationSettingsPage() {
const gateway = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
const accounts = useQuery<AccountsResponse>('/admin/api/accounts');
const { busy, run } = useAction();
const { wrap } = useToast();
const [display, setDisplay] = useState<string | null>(null);
const [preferences, setPreferences] = useState<Record<string, NotificationPreferences>>({});
useEffect(() => {
if (display === null && gateway.data) {
setDisplay(gateway.data.settings.notificationDisplay || 'home_only');
}
}, [display, gateway.data]);
useEffect(() => {
const loaded = accounts.data;
if (!loaded) return;
setPreferences((current) => {
const next = { ...current };
for (const account of loaded.accounts ?? []) {
if (!next[account.id]) next[account.id] = { ...account.notifications };
}
return next;
});
}, [accounts.data]);
const saveDisplay = () =>
run('display', async () => {
if (!gateway.data || display === null) return;
const saved = await wrap(
async () => {
// The endpoint stores one server-settings document. Read it again immediately
// before changing this field so a save in another console tab is not replaced
// by the older copy this page originally loaded.
const latest = await api.get<GatewaySettingsResponse>('/admin/api/gateway-settings');
return api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
...latest.settings,
notificationDisplay: display,
});
},
'Television notification display saved.',
);
if (saved) {
gateway.set(saved);
setDisplay(saved.settings.notificationDisplay);
}
});
const saveAccount = (account: NotificationAccount) =>
run(`account-${account.id}`, async () => {
const next = preferences[account.id];
if (!next) return;
const saved = await wrap(
() => api.put<NotificationPreferences>(
`/admin/api/accounts/${encodeURIComponent(account.id)}/notifications`,
next,
),
`Notification settings saved for ${account.shortName || account.username}.`,
);
if (saved) {
setPreferences((current) => ({ ...current, [account.id]: saved }));
accounts.set({
accounts: (accounts.data?.accounts ?? []).map((entry) =>
entry.id === account.id ? { ...entry, notifications: saved } : entry,
),
});
}
});
const error = gateway.error || accounts.error;
const rows = accounts.data?.accounts ?? [];
return (
<>
<PageHead
title="TV notifications"
intro="Choose where household notices may appear, then mute or allow them for each person."
actions={<Link className="btn" to="/admin/notifications">View notification history</Link>}
/>
<Banner message={error} />
{gateway.loading || display === null ? (
<Loading rows={2} />
) : (
<Card
title="Where notifications appear"
intro="This household-wide rule applies to every user and television. Maintenance and mandatory update screens still appear because they are not optional notifications."
icon="tv"
tone="info"
actions={<Tag tone={display === 'off' ? undefined : 'ok'}>{displayChoices.find((choice) => choice.value === display)?.label ?? 'Home only'}</Tag>}
footer={
<Button
variant="primary"
icon="check"
busy={busy === 'display'}
onClick={() => void saveDisplay()}
>
Save display rule
</Button>
}
>
<div className="checks columns">
{displayChoices.map((choice) => (
<label className="check" key={choice.value}>
<input
type="radio"
name="notification-display"
checked={display === choice.value}
onChange={() => setDisplay(choice.value)}
/>
<span className="switch" aria-hidden="true" />
<span className="check-body">
<b>{choice.label}</b>
<p>{choice.description}</p>
</span>
</label>
))}
</div>
</Card>
)}
<Card
title="People"
intro="A person's master switch follows them to every television. Open their user page to choose individual notification types."
icon="people"
tone="note"
>
{accounts.loading ? (
<Loading />
) : rows.length === 0 ? (
<p className="empty">No one has signed in to Memby yet.</p>
) : (
<Grid cols="2">
{rows.map((account) => {
const value = preferences[account.id] ?? account.notifications;
const name = account.shortName || account.username || 'Unnamed user';
const changed = value.enabled !== account.notifications.enabled;
return (
<Card
key={account.id}
title={name}
intro={account.shortName ? account.username : 'Memby user'}
icon="person"
tone="note"
actions={<Tag tone={value.enabled ? 'ok' : undefined}>{value.enabled ? 'enabled' : 'muted'}</Tag>}
footer={
<>
<Button
variant="primary"
busy={busy === `account-${account.id}`}
disabled={!changed}
onClick={() => void saveAccount(account)}
>
Save
</Button>
<Link className="btn" to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
Choose notification types
</Link>
</>
}
>
<Toggle
label="All notifications"
hint="Turn off every optional notification for this person."
checked={value.enabled}
onChange={(enabled) =>
setPreferences((current) => ({
...current,
[account.id]: { ...value, enabled },
}))
}
/>
</Card>
);
})}
</Grid>
)}
</Card>
</>
);
}
+1
View File
@@ -158,6 +158,7 @@ export function NotificationsPage() {
<PageHead
title="Notifications"
intro="Everything Memby sent — a viewer's own news, the bar every television draws, and each outbound webhook — with what became of it. Every feature reports through one notification service, so this is the whole trail rather than whichever half a feature remembered to log."
actions={<Link className="btn" to="/admin/notification-settings">TV notification settings</Link>}
/>
<Banner message={log.error} />
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.06"
val defaultVersionName = "0.3.07"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -92,6 +92,7 @@ class MaintenanceMonitor(
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
private val _preferencesRevision = MutableStateFlow(0L)
private val _seriesStatusRevision = MutableStateFlow(0L)
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
private val _theme = MutableStateFlow(GatewayThemeStatus())
@@ -136,6 +137,12 @@ class MaintenanceMonitor(
*/
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
/**
* Revision of the server's persisted Sonarr lifecycle catalogue. Detail metadata is
* process-cached, so a changed value tells the launcher to discard old series facts.
*/
val seriesStatusRevision: StateFlow<Long> = _seriesStatusRevision.asStateFlow()
/** One household-wide composition, delivered by the status poll to every viewer. */
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
val metadataHeroTimeRemainingColour: StateFlow<String> =
@@ -288,6 +295,7 @@ class MaintenanceMonitor(
// clearing it here would fight that loop for the same flow.
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_seriesStatusRevision.value = 0
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
@@ -327,6 +335,7 @@ class MaintenanceMonitor(
null
}
_preferencesRevision.value = status.preferencesRevision
_seriesStatusRevision.value = status.seriesStatusRevision
_metadataHeroContentOrder.value = status.metadataHeroContentOrder
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
.distinct()
@@ -382,6 +391,7 @@ class MaintenanceMonitor(
_compatibility.value = null
_embyOutage.value = null
_preferencesRevision.value = 0
_seriesStatusRevision.value = 0
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
@@ -416,9 +416,9 @@ data class BaseItem(
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
/**
* Emby's production status for a series — "Continuing" or "Ended". Only the detail
* call asks for it; a row item carries none, which is why [isOngoingSeries] treats
* absence as "not known to be running" rather than guessing either way.
* Canonical production status for a series. In gateway mode the detail response
* replaces Emby's value with the latest stored Sonarr observation where one matches;
* direct mode keeps Emby as the only source.
*/
@SerialName("Status") val status: String? = null,
// Emby returns these on episodes without being asked, so they cost no extra Fields.
@@ -504,15 +504,15 @@ data class BaseItem(
val isRadarrOnly: Boolean get() = isMovieSchedule && membyMovieItemId.isNullOrBlank()
/**
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
* one, since it knows about a season announced but not yet imported; Emby's own
* [status] is the fallback and the only source the direct path has. Neither present
* means no, which keeps the pace estimate saying "finish" — the weaker claim.
* Whether more episodes are expected. The canonical detail [status] wins; schedule
* lifecycle metadata is only the opening-frame fallback before that record arrives.
* Neither present means no, which keeps the pace estimate saying "finish" — the weaker
* claim.
*/
val isOngoingSeries: Boolean
get() = when {
membyLifecycle != null -> membyLifecycle.equals("continuing", ignoreCase = true)
else -> status.equals("Continuing", ignoreCase = true)
status != null -> status.equals("Continuing", ignoreCase = true)
else -> membyLifecycle.equals("continuing", ignoreCase = true)
}
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
* on the poll the app is already making.
*/
val preferencesRevision: Long = 0,
/** Monotonic revision of the gateway's stored Sonarr series lifecycle catalogue. */
val seriesStatusRevision: Long = 0,
/** Household-wide order of the focused metadata hero's content blocks. */
val metadataHeroContentOrder: List<String> = emptyList(),
/** Household-wide colour for the optional time-remaining caption: green or white. */
@@ -324,6 +324,7 @@ internal data class DetailsReturn(
internal fun FocusedDetailsOverlay(
homeViewModel: HomeViewModel,
selected: BaseItem,
seriesStatusRevision: Long = 0,
restorePosition: Boolean,
onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit,
@@ -345,12 +346,20 @@ internal fun FocusedDetailsOverlay(
// Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
// still consulted for live user state, but it cannot replace the full record with the
// lightweight Search card when focus returns behind the overlay.
var detailMetadata by remember(selected.id) {
mutableStateOf(homeViewModel.detailMetadataSnapshot(selected.id))
var detailMetadata by remember(selected.id, seriesStatusRevision) {
mutableStateOf(
homeViewModel.detailMetadataSnapshot(selected.id, seriesStatusRevision),
)
}
LaunchedEffect(selected.id, selected.isRadarrOnly, selected.isSchedule) {
LaunchedEffect(
selected.id,
selected.isRadarrOnly,
selected.isSchedule,
seriesStatusRevision,
) {
if (!selected.isRadarrOnly && !selected.isSchedule) {
homeViewModel.loadDetailMetadata(selected.id)?.let { detailMetadata = it }
homeViewModel.loadDetailMetadata(selected.id, seriesStatusRevision)
?.let { detailMetadata = it }
}
}
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
@@ -261,6 +261,8 @@ internal fun HomeScreen(
)
}
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val seriesStatusRevision by
ServiceLocator.maintenance.seriesStatusRevision.collectAsStateWithLifecycle()
val metadataHeroContentOrder by
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
val metadataHeroTimeRemainingColour by
@@ -2193,6 +2195,7 @@ internal fun HomeScreen(
FocusedDetailsOverlay(
homeViewModel = homeViewModel,
selected = selected,
seriesStatusRevision = seriesStatusRevision,
restorePosition = restoreDetailPosition,
airingNotice = detailsAiringNotice,
onOpenItem = { related ->
@@ -177,6 +177,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
}
private val metadataInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
private var seriesStatusRevision = 0L
/** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics()
@@ -637,8 +638,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* into it. A detail overlay uses this for its opening frame, then [loadDetailMetadata]
* joins an existing request or starts the missing one.
*/
internal fun detailMetadataSnapshot(itemId: String): BaseItem? =
synchronized(metadataCache) { metadataCache[itemId] }
internal fun detailMetadataSnapshot(itemId: String, statusRevision: Long = 0): BaseItem? =
synchronized(metadataCache) {
acceptSeriesStatusRevision(statusRevision)
metadataCache[itemId]
}
/**
* Loads one full item record, shared by focus prefetch and the detail overlay.
@@ -649,10 +653,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* reopened detail page relies on. The request lives in [viewModelScope], so losing
* focus or closing the first page does not cancel work a reopened page is awaiting.
*/
internal suspend fun loadDetailMetadata(itemId: String): BaseItem? {
internal suspend fun loadDetailMetadata(itemId: String, statusRevision: Long = 0): BaseItem? {
if (itemId.isBlank()) return null
var cached: BaseItem? = null
val request = synchronized(metadataCache) {
acceptSeriesStatusRevision(statusRevision)
cached = metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }
if (cached != null) {
null
@@ -663,8 +668,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
return cached ?: request?.await()
}
/** Must be called while synchronised on [metadataCache]. */
private fun acceptSeriesStatusRevision(revision: Long) {
if (revision <= 0L || revision == seriesStatusRevision) return
seriesStatusRevision = revision
metadataInFlight.values.forEach { it.cancel() }
metadataInFlight.clear()
metadataCache.clear()
}
private fun newDetailMetadataRequest(itemId: String): Deferred<BaseItem?> {
val request = viewModelScope.async(
val revisionAtStart = seriesStatusRevision
lateinit var request: Deferred<BaseItem?>
request = viewModelScope.async(
context = Dispatchers.IO,
start = CoroutineStart.LAZY,
) {
@@ -672,10 +688,18 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
runCatching { repository.getItemDetails(itemId) }
.getOrNull()
?.also { details ->
synchronized(metadataCache) { metadataCache[itemId] = details }
synchronized(metadataCache) {
if (seriesStatusRevision == revisionAtStart) {
metadataCache[itemId] = details
}
}
}
} finally {
synchronized(metadataCache) { metadataInFlight.remove(itemId) }
synchronized(metadataCache) {
if (metadataInFlight[itemId] === request) {
metadataInFlight.remove(itemId)
}
}
}
}
metadataInFlight[itemId] = request
@@ -385,14 +385,13 @@ internal fun SeriesDetailContent(
// lists, and a new identity for them on every focus move recomposes the fact row.
val heroFacts = remember(
item.id,
seasons.size,
item.productionYear,
item.officialRating,
item.runTimeTicks,
item.status,
item.membyLifecycle,
item.membyLifecycleText,
) {
heroFacts(item, seasons.size)
heroFacts(item)
}
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
@@ -64,21 +64,18 @@ fun remainingLabel(item: BaseItem): String? {
}
/**
* The quiet line directly under the title: year, length, certificate and, for a series,
* its production status. The score sits beside the title and the genres are a credit row,
* so non-series titles stay at three items and never have to compete for the width.
*
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
* The quiet line directly under the title. A series uses the stable three-part contract
* year, episode runtime and production status; other media keeps its certificate there.
* The score sits beside the title and the genres are a credit row.
*/
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
fun heroFacts(item: BaseItem): List<String> = buildList {
item.productionYear?.let { add(it.toString()) }
if (seasonCount > 0) {
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
} else {
item.runtimeMinutes?.let { add(formatRuntime(it)) }
}
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
if (item.isSeries) {
seriesStatusLabel(item)?.let(::add)
} else {
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
}
}
/**
@@ -90,14 +87,15 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
*/
fun seriesStatusLabel(item: BaseItem): String? {
if (!item.isSeries) return null
val status = item.membyLifecycleText?.takeIf(String::isNotBlank)
val status = item.status?.takeIf(String::isNotBlank)
?: item.membyLifecycleText?.takeIf(String::isNotBlank)
?: item.membyLifecycle?.takeIf(String::isNotBlank)
?: item.status?.takeIf(String::isNotBlank)
?: return null
return when (status.trim().lowercase(Locale.US)) {
"continuing" -> "Continuing"
"ended" -> "Ended"
"cancelled", "canceled" -> "Cancelled"
"upcoming" -> "Upcoming"
else -> null
}
}
@@ -0,0 +1,292 @@
package com.ponzischeme89.memby.ui.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.TitleLogoImage
import com.ponzischeme89.memby.ui.cinematicBackdropPullBack
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.useTextTitleForLogo
/** The already-resolved metadata needed to render a pause without doing repository work. */
internal data class PauseMediaHeroMetadata(
val title: String = "",
val seriesName: String? = null,
val episodeCode: String? = null,
val overview: String = "",
val logoUrl: String? = null,
val backdropUrl: String? = null,
val primaryArtworkUrl: String? = null,
) {
val isEpisode: Boolean get() = !episodeCode.isNullOrBlank() || !seriesName.isNullOrBlank()
}
/**
* Memby's paused-playback identity surface.
*
* The player owns only [visible] and immutable metadata. This component owns every visual
* layer, remains non-interactive, and therefore cannot enter or alter the transport's TV
* focus graph.
*/
@Composable
internal fun PauseMediaHero(
metadata: PauseMediaHeroMetadata,
visible: Boolean,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible,
modifier = modifier,
enter = fadeIn(tween(PauseHeroEnterMs)) +
slideInVertically(tween(PauseHeroEnterMs)) { height -> height / 40 },
exit = fadeOut(tween(PauseHeroExitMs)) +
slideOutVertically(tween(PauseHeroExitMs)) { height -> height / 60 },
) {
Box(Modifier.fillMaxSize()) {
PauseHeroBackdrop(metadata.backdropUrl)
PauseHeroContent(
metadata = metadata,
modifier = Modifier
.fillMaxSize()
.padding(start = 48.dp, top = 34.dp, end = 48.dp, bottom = 214.dp),
)
}
}
}
@Composable
private fun PauseHeroBackdrop(backdropUrl: String?) {
val context = LocalContext.current
val request = remember(backdropUrl, context) {
backdropUrl?.let {
ImageRequest.Builder(context)
.data(it)
.size(1280, 720)
.allowHardware(true)
.crossfade(false)
.build()
}
}
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.74f))) {
if (request != null) {
AsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.CenterEnd,
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxWidth(0.74f)
.fillMaxHeight()
.cinematicBackdropPullBack(backdropUrl),
)
}
// These are the same layered directions as the Home Metadata Hero: a firm text
// field at the start, a controlled reveal through the artwork, and a dark landing
// beneath the transport. The frozen video remains present only as a subdued base.
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to MembySurface.copy(alpha = 0.98f),
0.34f to MembySurface.copy(alpha = 0.92f),
0.56f to MembySurface.copy(alpha = 0.56f),
0.78f to Color.Black.copy(alpha = 0.28f),
1f to Color.Black.copy(alpha = 0.18f),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.24f),
0.55f to Color.Transparent,
0.78f to Color.Black.copy(alpha = 0.34f),
1f to Color.Black.copy(alpha = 0.88f),
),
),
)
}
}
@Composable
private fun PauseHeroContent(
metadata: PauseMediaHeroMetadata,
modifier: Modifier = Modifier,
) {
Column(modifier, verticalArrangement = Arrangement.Top) {
Text(
text = stringResource(R.string.player_paused),
color = MembyAccentBright,
fontSize = 12.sp,
lineHeight = 15.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.18.em,
)
Spacer(Modifier.height(10.dp))
PauseHeroTitle(metadata)
if (metadata.isEpisode) {
pauseHeroEpisodeLabel(metadata)?.let { label ->
Spacer(Modifier.height(8.dp))
Text(
text = label,
color = MembyOnSurface,
fontSize = 18.sp,
lineHeight = 23.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(18.dp))
PauseHeroSummary(metadata)
}
}
@Composable
private fun PauseHeroTitle(metadata: PauseMediaHeroMetadata) {
val heading = metadata.seriesName?.takeIf { metadata.isEpisode && it.isNotBlank() }
?: metadata.title.takeIf(String::isNotBlank)
?: stringResource(R.string.player_now_playing)
val logo = metadata.logoUrl?.takeIf { !useTextTitleForLogo(it) }
Box(
modifier = Modifier.fillMaxWidth(0.52f).heightIn(max = 76.dp),
contentAlignment = Alignment.CenterStart,
) {
if (logo != null) {
TitleLogoImage(
logoUrl = logo,
contentDescription = heading,
alignment = Alignment.CenterStart,
modifier = Modifier.width(340.dp).height(76.dp),
)
} else {
Text(
text = heading,
color = Color.White,
fontSize = 34.sp,
lineHeight = 38.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun PauseHeroSummary(metadata: PauseMediaHeroMetadata) {
val synopsis = metadata.overview.ifBlank {
stringResource(R.string.player_pause_overview_fallback)
}
Row(
modifier = Modifier.fillMaxWidth(0.64f),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.Top,
) {
metadata.primaryArtworkUrl?.let { artwork ->
AsyncImage(
model = artwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = if (metadata.isEpisode) {
Modifier.width(218.dp).aspectRatio(16f / 9f)
.clip(RoundedCornerShape(MembyCardCorner))
.background(MembySurface)
} else {
Modifier.size(width = 116.dp, height = 174.dp)
.clip(RoundedCornerShape(MembyCardCorner))
.background(MembySurface)
},
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = synopsis,
color = MembyMutedText,
fontSize = 16.sp,
lineHeight = 22.sp,
maxLines = if (metadata.isEpisode) 5 else 7,
overflow = TextOverflow.Ellipsis,
)
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
Text(
text = stringResource(R.string.player_pause_resume_hint),
color = MembyOnSurface.copy(alpha = 0.76f),
fontSize = 13.sp,
)
}
}
}
internal fun pauseHeroEpisodeLabel(metadata: PauseMediaHeroMetadata): String? {
if (!metadata.isEpisode) return null
val code = metadata.episodeCode?.trim().orEmpty()
val spacedCode = Regex("^S(\\d{1,2})E(\\d{1,3})$", RegexOption.IGNORE_CASE)
.matchEntire(code)
?.destructured
?.let { (season, episode) -> "S${season.padStart(2, '0')} E${episode.padStart(2, '0')}" }
?: code.takeIf(String::isNotBlank)
val episodeTitle = pauseHeroEpisodeTitle(metadata.title, metadata.seriesName)
return listOfNotNull(spacedCode, episodeTitle).joinToString("").takeIf(String::isNotBlank)
}
private fun pauseHeroEpisodeTitle(title: String, seriesName: String?): String? {
var episodeTitle = title.trim()
val series = seriesName?.trim().orEmpty()
if (series.isNotEmpty()) {
listOf(" ", "", " - ").firstOrNull { separator ->
episodeTitle.startsWith(series + separator, ignoreCase = true)
}?.let { separator -> episodeTitle = episodeTitle.drop(series.length + separator.length).trim() }
}
return episodeTitle.takeIf { it.isNotBlank() && !it.equals(series, ignoreCase = true) }
}
private const val PauseHeroEnterMs = 180
private const val PauseHeroExitMs = 120
@@ -29,8 +29,10 @@ import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -243,6 +245,8 @@ class PlayerActivity : ComponentActivity() {
private var configuredPrerollDurationMs = DEFAULT_PREROLL_DURATION_MS
private var pausePosterUrl: String? = null
private var pauseOverlay: View? = null
private val pauseHeroVisible = mutableStateOf(false)
private val pauseHeroMetadata = mutableStateOf(PauseMediaHeroMetadata())
private var nowPlayingGroup: View? = null
private var playbackIdentityView: View? = null
private var playbackIdentityHideJob: Job? = null
@@ -1335,7 +1339,7 @@ class PlayerActivity : ComponentActivity() {
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
updatePauseHeroMetadata()
}
private fun startPreroll() {
@@ -2358,7 +2362,7 @@ class PlayerActivity : ComponentActivity() {
*/
private fun showPlaybackIdentity() {
val identity = playbackIdentityView ?: return
val paused = pauseOverlay?.visibility == View.VISIBLE
val paused = pauseHeroVisible.value
if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) {
MembyDiagnostics.debug(
"station_ident_withheld",
@@ -2440,7 +2444,7 @@ class PlayerActivity : ComponentActivity() {
val slot = playerIdentitySlot(
identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING,
transportVisible = transportVisible,
paused = pauseOverlay?.visibility == View.VISIBLE,
paused = pauseHeroVisible.value,
)
nowPlayingGroup?.visibility =
if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE
@@ -4328,19 +4332,7 @@ class PlayerActivity : ComponentActivity() {
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text =
pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
pauseOverlay?.findViewById<ImageView>(R.id.player_pause_poster)?.apply {
val poster = pausePosterUrl
if (poster.isNullOrBlank()) {
visibility = View.GONE
setImageDrawable(null)
} else {
visibility = View.VISIBLE
load(poster) { crossfade(true) }
}
}
updatePauseHeroMetadata()
hidePlaybackError()
showPlaybackLoading()
if (playback != null) {
@@ -4378,30 +4370,47 @@ class PlayerActivity : ComponentActivity() {
}
private fun bindPauseOverlay(view: PlayerView) {
pauseOverlay = view.findViewById(R.id.player_pause_overlay)
pauseOverlay = view.findViewById<ComposeView>(R.id.player_pause_overlay).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val visible by pauseHeroVisible
val metadata by pauseHeroMetadata
MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) {
PauseMediaHero(
metadata = metadata,
visible = visible,
modifier = Modifier.fillMaxSize(),
)
}
}
}
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group)
// The group is visible in the layout, so state it here too: nothing else runs before
// the transport is first raised, and the ident's five seconds are inside that window.
applyIdentityRegion()
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply {
text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
}
pauseOverlay?.findViewById<ImageView>(R.id.player_pause_poster)?.apply {
val poster = pausePosterUrl
if (poster.isNullOrBlank()) {
visibility = View.GONE
} else {
visibility = View.VISIBLE
load(poster) { crossfade(true) }
}
updatePauseHeroMetadata()
}
private fun updatePauseHeroMetadata() {
pauseHeroMetadata.value = PauseMediaHeroMetadata(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
overview = pauseOverview,
logoUrl = logoUrl,
backdropUrl = loadingBackdropUrl,
primaryArtworkUrl = pausePosterUrl,
)
}
private fun updatePauseOverlay(playback: Player) {
val paused = playbackStarted && !prerollActive &&
playback.playbackState == Player.STATE_READY && !playback.isPlaying
pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE
// Metadata can change without rebuilding the activity (next episode, preview,
// refreshed stream). Bind it at the state edge, before the enter transition starts,
// so pausing never shows the outgoing programme for a frame.
if (paused) updatePauseHeroMetadata()
pauseHeroVisible.value = paused
// Pausing during the ident hands the corner to the pause overlay, which carries the
// poster, the title and the synopsis: an ident over the top of that is the same
// programme announced twice, in two type sizes, in overlapping space.
@@ -1,80 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_pause_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:visibility="gone">
<View
android:layout_width="820dp"
android:layout_height="match_parent"
android:background="@drawable/player_pause_scrim" />
<LinearLayout
android:layout_width="760dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="48dp"
android:layout_marginBottom="62dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/player_pause_poster"
android:layout_width="164dp"
android:layout_height="246dp"
android:background="@drawable/player_pause_poster_background"
android:clipToOutline="true"
android:contentDescription="@string/player_pause_poster"
android:outlineProvider="background"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.18"
android:text="@string/player_paused"
android:textColor="#FF6BCB63"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_pause_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFFFF"
android:textSize="34sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_pause_overview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:ellipsize="end"
android:lineSpacingExtra="3dp"
android:maxLines="5"
android:textColor="#DDE7EBEE"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/player_pause_resume_hint"
android:textColor="#B8FFFFFF"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
android:importantForAccessibility="no"
android:visibility="visible" />
@@ -197,10 +197,11 @@ class GatewayPayloadTest {
@Test
fun `decodes the settings revision an operator push arrives as`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"preferencesRevision":12}""",
"""{"maintenance":false,"preferencesRevision":12,"seriesStatusRevision":34}""",
)
assertEquals(12L, status.preferencesRevision)
assertEquals(34L, status.seriesStatusRevision)
}
/**
@@ -100,13 +100,14 @@ class SeriesDetailsTest {
name = "Series",
type = "Series",
productionYear = 2022,
runTimeTicks = 31_200_000_000L,
officialRating = "TV-MA",
status = "Continuing",
)
assertEquals(
listOf("2022", "4 Seasons", "TV-MA", "Continuing"),
heroFacts(series, seasonCount = 4),
listOf("2022", "52m", "Continuing"),
heroFacts(series),
)
}
@@ -120,8 +121,8 @@ class SeriesDetailsTest {
membyLifecycleText = "CANCELED",
)
assertEquals("Cancelled", heroFacts(series).last())
assertEquals("Ended", heroFacts(series.copy(membyLifecycleText = null)).last())
assertEquals("Ended", heroFacts(series).last())
assertEquals("Cancelled", heroFacts(series.copy(status = null)).last())
}
@Test
@@ -141,7 +142,7 @@ class SeriesDetailsTest {
status = "Continuing",
)
assertEquals(listOf("2022"), heroFacts(unknownSeries))
assertEquals(listOf("2022", "Upcoming"), heroFacts(unknownSeries))
assertEquals(listOf("2022"), heroFacts(movie))
}
}
@@ -0,0 +1,41 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PauseMediaHeroTest {
@Test
fun episodeLabelSeparatesSeasonEpisodeAndTitle() {
assertEquals(
"S04 E06 • The Dive",
pauseHeroEpisodeLabel(
PauseMediaHeroMetadata(
title = "Northbound The Dive",
seriesName = "Northbound",
episodeCode = "S04E06",
),
),
)
}
@Test
fun episodeLabelDoesNotRepeatSeriesName() {
assertEquals(
"S01 E02",
pauseHeroEpisodeLabel(
PauseMediaHeroMetadata(
title = "Harbour Lights",
seriesName = "Harbour Lights",
episodeCode = "S01E02",
),
),
)
}
@Test
fun movieHasNoEpisodeLabel() {
assertNull(pauseHeroEpisodeLabel(PauseMediaHeroMetadata(title = "Uproar")))
}
}
@@ -7,8 +7,18 @@ import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
@@ -20,23 +30,43 @@ import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class PlayerPauseOverlayScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `paused movie shows focused poster synopsis and resume controls`() {
val (activity, root, controls) = playerSurface()
fun `paused episode shows darkened hero artwork and synopsis`() {
val preview = requireNotNull(previewArtwork())
val artworkUrl = requireNotNull(javaClass.classLoader?.getResource("home_hero_preview_art.png"))
.toExternalForm()
compose.setContent {
MembyTheme {
Box(Modifier.fillMaxSize()) {
Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
PauseMediaHero(
metadata = PauseMediaHeroMetadata(
title = "Northbound The Last Horizon",
seriesName = "Northbound",
episodeCode = "S04E06",
overview = "A cartographer follows a signal beyond the edge of the known world, " +
"where an abandoned observatory may hold the way home.",
backdropUrl = artworkUrl,
primaryArtworkUrl = artworkUrl,
),
visible = true,
modifier = Modifier.fillMaxSize(),
)
}
}
}
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.VISIBLE
controls.findViewById<View>(R.id.player_now_playing_group).visibility = View.GONE
controls.findViewById<TextView>(R.id.player_pause_title).text = "The Last Horizon"
controls.findViewById<TextView>(R.id.player_pause_overview).text =
"A cartographer follows a signal beyond the edge of the known world, " +
"where an abandoned observatory may hold the way home."
controls.findViewById<ImageView>(R.id.player_pause_poster).setImageBitmap(previewArtwork())
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
root.captureRoboImage("build/screenshots/player-pause-overlay/player-paused-movie-overlay.png")
compose.onRoot().captureRoboImage(
"build/screenshots/player-pause-overlay/player-paused-episode-hero.png",
)
}
@Test
+3
View File
@@ -362,6 +362,9 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
if _, ok := body["preferencesRevision"]; !ok {
t.Fatalf("status response carried no preferences revision: %v", body)
}
if _, ok := body["seriesStatusRevision"]; !ok {
t.Fatalf("status response carried no series status revision: %v", body)
}
metadataOrder, ok := body["metadataHeroContentOrder"].([]any)
if !ok || len(metadataOrder) == 0 {
t.Fatalf("status response carried no global metadata hero order: %v", body)
+1 -1
View File
@@ -38,7 +38,7 @@ const (
// tab's vocabulary and are read nowhere else. They belong here for the same reason: this
// call is made once, after D-pad focus has settled on a card, and every one of them
// would be a per-card cost on a home row.
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ProviderIds,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
+12 -4
View File
@@ -48,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if raw, err := s.cache.Get(ctx, itemDetailKey(viewerKeyOf(ctx, sess), itemID)); err == nil {
if raw, err := s.cache.Get(ctx, s.itemDetailKey(ctx, viewerKeyOf(ctx, sess), itemID)); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
@@ -65,8 +65,16 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
// itemDetailKey is versioned so that when the detail contract grows, older cached payloads
// cannot hide newly requested fields such as People or the stored ratings.
func itemDetailKey(userID, itemID string) string {
return cache.UserKey(userID, "item:v6:"+itemID)
func (s *Server) itemDetailKey(ctx context.Context, userID, itemID string) string {
revision := int64(0)
if s.store != nil {
if stored, err := s.store.SonarrSeriesStatusRevision(ctx); err == nil {
revision = stored
} else {
s.loggerFor(ctx).Warn("series status revision unavailable for item cache", "error", err)
}
}
return cache.UserKey(userID, "item:v7:r"+strconv.FormatInt(revision, 10)+":"+itemID)
}
// detailItem is the full record for one item, decorated and kept.
@@ -77,7 +85,7 @@ func itemDetailKey(userID, itemID string) string {
func (s *Server) detailItem(
ctx context.Context, sess store.Session, itemID string,
) (json.RawMessage, error) {
item, _, err := s.cachedRead(ctx, itemDetailKey(sess.EmbyUserID, itemID), s.cfg.ItemTTL,
item, _, err := s.cachedRead(ctx, s.itemDetailKey(ctx, sess.EmbyUserID, itemID), s.cfg.ItemTTL,
func(ctx context.Context) (json.RawMessage, error) {
raw, err := s.emby.Item(
timing.WithLabel(ctx, "emby.item"), credentials(sess), itemID, fieldsDetail)
+2
View File
@@ -26,6 +26,8 @@ func seriesLifecycleTag(status string) lifecycleTag {
return lifecycleTag{Status: "upcoming", Label: "UPCOMING"}
case "ended":
return lifecycleTag{Status: "ended", Label: "ENDED"}
case "cancelled", "canceled":
return lifecycleTag{Status: "cancelled", Label: "CANCELLED"}
case "deleted":
return lifecycleTag{Status: "deleted", Label: "REMOVED"}
default:
+2
View File
@@ -15,6 +15,8 @@ func TestSeriesLifecycleTag(t *testing.T) {
"Continuing": {Status: "continuing", Label: "CONTINUING"},
"upcoming": {Status: "upcoming", Label: "UPCOMING"},
"ended": {Status: "ended", Label: "ENDED"},
"cancelled": {Status: "cancelled", Label: "CANCELLED"},
"canceled": {Status: "cancelled", Label: "CANCELLED"},
"deleted": {Status: "deleted", Label: "REMOVED"},
// No tag at all rather than an invented one.
"": {},
+11
View File
@@ -132,6 +132,14 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
compatible, compatibilityMessage := compatibilityFor(r)
featurePolicy := s.currentFeaturePolicy(r.Context())
metadataHero := s.metadataHeroSettings.get()
seriesStatusRevision := int64(0)
if s.store != nil {
if revision, err := s.store.SonarrSeriesStatusRevision(r.Context()); err != nil {
s.loggerFor(r.Context()).Warn("series status revision unavailable", "error", err)
} else {
seriesStatusRevision = revision
}
}
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"quietTime": quiet.Active,
@@ -156,6 +164,9 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
// fetches /v1/preferences when they differ. That is what turns this poll into the
// delivery channel for an operator pushing someone's settings.
"preferencesRevision": s.preferenceRevisionFor(r, sess),
// The TV keeps full item details in memory. This revision lets a daily Sonarr
// transition evict that local copy before the same show is opened again.
"seriesStatusRevision": seriesStatusRevision,
// The small metadata hero presentation document rides the existing poll so a saved
// layout or colour reaches an open launcher immediately, without manufacturing a
// per-user preference revision for a global change.
+5 -4
View File
@@ -236,11 +236,12 @@ func (s *Server) decorateHomeRatings(ctx context.Context, out *homeResponse) {
// decorateItems is the one door items leave the gateway through.
//
// It attaches both of the things Memby knows about a title that Emby's payload does not
// carry: the stored review scores, and — for a shadow viewer — whose progress this is. The
// two are separate concerns and stayed separate functions, but every call site wanted both,
// and a decoration added at seven sites is a decoration missing from the eighth.
// It attaches the facts Memby knows about a title beyond Emby's payload: the latest stored
// Sonarr series status, stored review scores, and — for a shadow viewer — whose progress
// this is. The concerns stay in separate functions, but every call site needs one door;
// a decoration added at seven sites is a decoration missing from the eighth.
func (s *Server) decorateItems(ctx context.Context, collections ...[]json.RawMessage) {
s.decorateSeriesStatuses(ctx, collections...)
s.decorateItemRatings(ctx, collections...)
s.decorateViewerState(ctx, collections...)
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"context"
"encoding/json"
"strconv"
"strings"
)
// decorateSeriesStatuses makes Status on an ordinary Emby series the canonical detail
// answer. Sonarr's daily observation wins when it is recognised; otherwise Emby's field
// is left untouched as the fallback.
func (s *Server) decorateSeriesStatuses(ctx context.Context, collections ...[]json.RawMessage) {
if s.store == nil {
return
}
tvdbIDs := []int{}
seen := map[int]bool{}
identities := make([]map[int]int, len(collections))
for collectionIndex, items := range collections {
identities[collectionIndex] = map[int]int{}
for itemIndex, raw := range items {
tvdbID := seriesTVDBID(raw)
if tvdbID <= 0 {
continue
}
identities[collectionIndex][itemIndex] = tvdbID
if !seen[tvdbID] {
seen[tvdbID] = true
tvdbIDs = append(tvdbIDs, tvdbID)
}
}
}
if len(tvdbIDs) == 0 {
return
}
statuses, err := s.store.LatestSonarrSeriesStatuses(ctx, tvdbIDs)
if err != nil {
s.loggerFor(ctx).Warn("stored Sonarr series statuses unavailable", "error", err)
return
}
for collectionIndex, itemIndexes := range identities {
for itemIndex, tvdbID := range itemIndexes {
status := canonicalSonarrSeriesStatus(statuses[tvdbID])
if status != "" {
collections[collectionIndex][itemIndex] = injectSeriesStatus(
collections[collectionIndex][itemIndex], status,
)
}
}
}
}
func seriesTVDBID(raw json.RawMessage) int {
var item struct {
Type string `json:"Type"`
ProviderIDs map[string]string `json:"ProviderIds"`
}
if json.Unmarshal(raw, &item) != nil || !strings.EqualFold(item.Type, "Series") {
return 0
}
id, _ := strconv.Atoi(providerID(item.ProviderIDs, "tvdb"))
return id
}
// canonicalSonarrSeriesStatus is deliberately conservative. Unknown values are not
// copied over a usable Emby status, and American spelling is normalised at the render
// boundary before it can become viewer-facing text.
func canonicalSonarrSeriesStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "continuing":
return "Continuing"
case "ended":
return "Ended"
case "cancelled", "canceled":
return "Cancelled"
case "upcoming":
return "Upcoming"
default:
return ""
}
}
func injectSeriesStatus(raw json.RawMessage, status string) json.RawMessage {
if status == "" {
return raw
}
var members map[string]json.RawMessage
if json.Unmarshal(raw, &members) != nil || members == nil {
return raw
}
encoded, err := json.Marshal(status)
if err != nil {
return raw
}
members["Status"] = encoded
out, err := json.Marshal(members)
if err != nil {
return raw
}
return out
}
@@ -0,0 +1,49 @@
package api
import (
"encoding/json"
"testing"
)
func TestCanonicalSonarrSeriesStatus(t *testing.T) {
cases := map[string]string{
" continuing ": "Continuing",
"ended": "Ended",
"cancelled": "Cancelled",
"canceled": "Cancelled",
"upcoming": "Upcoming",
"deleted": "",
"unknown": "",
}
for input, want := range cases {
if got := canonicalSonarrSeriesStatus(input); got != want {
t.Errorf("canonicalSonarrSeriesStatus(%q) = %q, want %q", input, got, want)
}
}
}
func TestInjectSeriesStatusReplacesEmbyWithoutDroppingFields(t *testing.T) {
raw := json.RawMessage(`{"Id":"series-1","Status":"Continuing","FutureField":true}`)
decorated := injectSeriesStatus(raw, "Ended")
var item struct {
Status string `json:"Status"`
FutureField bool `json:"FutureField"`
}
if err := json.Unmarshal(decorated, &item); err != nil {
t.Fatal(err)
}
if item.Status != "Ended" || !item.FutureField {
t.Fatalf("decorated item = %+v", item)
}
}
func TestSeriesTVDBIDOnlyAcceptsSeries(t *testing.T) {
series := json.RawMessage(`{"Type":"Series","ProviderIds":{"Tvdb":"1234"}}`)
if got := seriesTVDBID(series); got != 1234 {
t.Fatalf("series TVDB id = %d, want 1234", got)
}
movie := json.RawMessage(`{"Type":"Movie","ProviderIds":{"Tvdb":"1234"}}`)
if got := seriesTVDBID(movie); got != 0 {
t.Fatalf("movie TVDB id = %d, want 0", got)
}
}
+40
View File
@@ -68,6 +68,46 @@ type SonarrSeriesStatusChange struct {
Current SonarrSeriesStatus
}
// LatestSonarrSeriesStatuses returns Sonarr's most recently observed lifecycle for each
// TVDB id. The history table is change-only, so the newest row is also the current value.
func (s *Store) LatestSonarrSeriesStatuses(
ctx context.Context, tvdbIDs []int,
) (map[int]string, error) {
statuses := map[int]string{}
if len(tvdbIDs) == 0 {
return statuses, nil
}
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (tvdb_id) tvdb_id, status
FROM sonarr_series_status_history
WHERE tvdb_id = ANY($1) AND tvdb_id > 0
ORDER BY tvdb_id, observed_at DESC, id DESC`, tvdbIDs)
if err != nil {
return nil, fmt.Errorf("store: read latest Sonarr series statuses: %w", err)
}
defer rows.Close()
for rows.Next() {
var tvdbID int
var status string
if err := rows.Scan(&tvdbID, &status); err != nil {
return nil, fmt.Errorf("store: scan latest Sonarr series status: %w", err)
}
statuses[tvdbID] = status
}
return statuses, rows.Err()
}
// SonarrSeriesStatusRevision is an opaque, monotonic version of the stored lifecycle
// catalogue. It changes whenever a scan records a first sighting or a transition.
func (s *Store) SonarrSeriesStatusRevision(ctx context.Context) (int64, error) {
var revision int64
if err := s.pool.QueryRow(ctx,
`SELECT COALESCE(max(id), 0) FROM sonarr_series_status_history`).Scan(&revision); err != nil {
return 0, fmt.Errorf("store: read Sonarr series status revision: %w", err)
}
return revision, nil
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)