0.2.69 - Homepage loading improvements pass

This commit is contained in:
ponzischeme89
2026-08-16 21:20:55 +12:00
parent b9374baaf1
commit 845fa349e4
23 changed files with 663 additions and 262 deletions
+9
View File
@@ -1,3 +1,12 @@
## 0.2.69 - 2026-08-16
- Fixed: Cached recommendation shelves remain visible while fresh recommendations rebuild, avoiding row flicker and recomposition.
- Fixed: “For You” content now loads only when opened, rather than competing with homepage requests.
- Fixed: Detail, related-title, episode and trailer prefetching waits until focus settles, preventing request storms during D-pad navigation.
- Fixed: Homepage artwork preloading is smaller and lifecycle-aware.
- Fixed: My Shows and notifications refresh concurrently.
- Fixed: Diagnostic logging avoids unnecessary production-path work.
- Fixed: Added time-to-interactive and improved frame/jank measurements, including a warm-start benchmark.
## 0.2.67 — 2026-08-16
- Improved: Movie and show pages.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
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-C4RyKtIU.js"></script>
<script type="module" crossorigin src="/admin/assets/index-CSt3yVnU.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-Dyz7s7hT.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CASotpHk.css">
</head>
<body>
<div id="root"></div>
+2 -6
View File
@@ -6,7 +6,6 @@ import { NotificationBell } from './NotificationBell';
import { nav } from '../nav';
import { useNotifications } from '../lib/notifications';
import { useGateway } from '../lib/gateway';
import { time } from '../lib/format';
import { Confirm } from './ui';
/* The shell: a top bar spanning the width, a rail down the left, and the page.
@@ -133,7 +132,7 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
}
export function Layout() {
const { version, currentUser, online, checkedAt, loading, status, setMaintenance } = useGateway();
const { version, currentUser, online, loading, status, setMaintenance } = useGateway();
const [railOpen, setRailOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
const [changingAvailability, setChangingAvailability] = useState(false);
@@ -143,7 +142,6 @@ export function Layout() {
const account = useRef<HTMLDivElement>(null);
const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A';
const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking';
const statusLabel = offline ? 'offline' : status && online ? 'online' : loading ? 'checking' : 'not responding';
const toggleAvailability = async () => {
if (changingAvailability || !status) return;
@@ -234,9 +232,7 @@ export function Layout() {
aria-label={offline ? 'Memby is offline. Bring it online' : status && online ? 'Memby is online. Take it offline' : loading ? 'Checking Memby status' : 'Memby is not responding'}
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
>
<span className="dot" />
<b>{statusLabel}</b>
<span>{offline ? 'click to bring back online' : checkedAt ? `updated ${time(checkedAt)}` : ''}</span>
<span className="dot" aria-hidden="true" />
</button>
<NotificationBell />
<div className="account-menu" data-open={accountOpen || undefined} ref={account}>
+1 -1
View File
@@ -74,7 +74,7 @@ export function OmniSearch() {
.filter((entry) => entry.rank > 0)
.sort((a, b) => b.rank - a.rank || a.index - b.index)
.map(({ item, rank }) => ({ item, rank }));
}, [query]);
}, [query, status]);
// Something is always selected, so Enter has an answer without an arrow press first.
useEffect(() => setCursor(0), [query]);
+69 -80
View File
@@ -208,18 +208,17 @@ a {
white-space: nowrap;
}
.topbar-status {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 6px;
border: 0;
display: grid;
place-items: center;
width: 38px;
height: 38px;
flex: 0 0 38px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
transition: background .16s ease, border-color .16s ease, color .16s ease;
}
.topbar-status:hover:not(:disabled),
@@ -227,37 +226,32 @@ a {
background: var(--surface-lift);
}
.topbar-status:disabled {
cursor: wait;
opacity: .65;
cursor: default;
opacity: 1;
}
.topbar-status .dot {
width: 8px;
height: 8px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--quiet);
flex: 0 0 8px;
box-shadow: 0 0 0 3px rgba(125, 133, 144, .12);
}
.topbar-status[data-tone="ok"] .dot {
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-wash), 0 0 12px rgba(86, 211, 100, .34);
background: var(--accent-ink);
box-shadow: 0 0 0 3px var(--accent-wash), 0 0 9px rgba(86, 211, 100, .28);
}
.topbar-status[data-tone="bad"] .dot {
background: var(--danger);
}
.topbar-status b {
font-size: 12.5px;
font-weight: 600;
}
.topbar-status span {
font-size: 11.5px;
color: var(--quiet);
white-space: nowrap;
box-shadow: 0 0 0 3px var(--danger-wash);
}
/* The account is an identity first and an exit second. Keeping sign-out in this menu
prevents an unexplained door icon from competing with the operational controls. */
.account-menu {
position: relative;
display: flex;
align-items: center;
height: 38px;
flex: 0 0 auto;
}
.account-trigger {
@@ -360,8 +354,7 @@ a {
}
@media (max-width: 900px) {
.topbar-version,
.topbar-status span {
.topbar-version {
display: none;
}
}
@@ -419,9 +412,10 @@ a {
.omni-panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: min(520px, 94vw);
max-height: min(60vh, 520px);
right: auto;
left: 0;
width: 100%;
max-height: min(50vh, 420px);
overflow-y: auto;
padding: 6px;
border: 1px solid var(--line);
@@ -475,13 +469,16 @@ a {
.bell {
position: relative;
display: flex;
align-items: center;
height: 38px;
}
.bell-button {
position: relative;
display: grid;
place-items: center;
width: 34px;
height: 34px;
width: 38px;
height: 38px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
@@ -1165,20 +1162,8 @@ table {
max-width: 640px;
min-width: 0;
}
.omni-input {
height: 36px;
}
.omni-panel {
position: fixed;
top: calc(var(--top) + 6px);
right: 12px;
left: 12px;
width: auto;
max-height: calc(100dvh - var(--top) - 18px);
}
.omni-key,
.topbar-version,
.topbar-status span {
.topbar-version {
display: none;
}
.topbar-tools {
@@ -1190,21 +1175,19 @@ table {
height: 40px;
}
.topbar-status {
width: 40px;
flex-basis: 40px;
min-width: 40px;
height: 40px;
padding: 0 10px;
border: 1px solid var(--line);
background: var(--surface);
}
.topbar-status[data-tone="ok"] {
border-color: rgba(86, 211, 100, .38);
background: var(--accent-wash);
color: var(--accent-ink);
}
.topbar-status[data-tone="bad"] {
border-color: rgba(229, 83, 75, .34);
background: var(--danger-wash);
color: var(--danger-ink);
.omni-input,
.bell,
.bell-button,
.account-menu,
.account-trigger {
height: 40px;
}
.page {
width: 100%;
@@ -1236,8 +1219,7 @@ table {
}
@media (max-width: 820px) {
.brand-word,
.topbar-status b {
.brand-word {
display: none;
}
.topbar-status {
@@ -1266,40 +1248,43 @@ table {
}
}
/* A phone gets two deliberate rows: navigation and live controls above, search below.
Compressing all six controls into one line made the search unusable at exactly the
widths where it is the quickest way around a long navigation drawer. */
/* A phone keeps every global control on one row. Search is the only flexible item: the
navigation and identity controls retain useful targets while the field gives up width
before the header can wrap. Its relative wrapper still anchors the results directly
beneath the field at exactly the width available here. */
@media (max-width: 680px) {
:root {
--top: calc(108px + var(--safe-top));
--top: calc(60px + var(--safe-top));
}
.topbar {
align-content: center;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: calc(var(--safe-top) + 8px) max(12px, var(--safe-right)) 8px max(12px, var(--safe-left));
flex-wrap: nowrap;
gap: clamp(4px, 1.5vw, 8px);
padding: calc(var(--safe-top) + 8px) max(8px, var(--safe-right)) 8px max(8px, var(--safe-left));
}
.rail-toggle { order: 1; }
.topbar-brand { order: 2; }
.topbar-status {
order: 3;
margin-left: auto;
.topbar-brand {
order: 2;
height: 40px;
flex: 0 0 auto;
}
.bell { order: 4; }
.account-menu { order: 5; }
.topbar .omni {
order: 6;
flex: 1 0 100%;
order: 3;
flex: 1 1 96px;
width: 0;
min-width: 0;
max-width: none;
}
.brand-mark {
width: 32px;
height: 32px;
flex-basis: 32px;
.topbar-status {
order: 4;
margin-left: 0;
}
.omni-input {
height: 38px;
.bell { order: 5; }
.account-menu { order: 6; }
.brand-mark {
width: 30px;
height: 30px;
flex-basis: 30px;
}
.account-panel,
.bell-panel {
@@ -3000,8 +2985,12 @@ details summary {
height: 44px;
padding: 0;
}
.topbar-status b {
margin-left: 1px;
.topbar .bell,
.topbar .account-menu {
height: 44px;
}
.topbar .omni-input {
height: 44px;
}
.rail a {
min-height: 46px;
+1 -1
View File
@@ -46,7 +46,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.68"
val defaultVersionName = "0.2.69"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -56,18 +56,40 @@ internal class DiagnosticNetworkInterceptor(private val backend: String) : Inter
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val started = android.os.SystemClock.elapsedRealtime()
MembyDiagnostics.trace("http_started", "backend" to backend, "method" to request.method, "url" to MembyDiagnostics.safeUrl(request.url))
if (MembyDiagnostics.traceEnabled) {
MembyDiagnostics.trace(
"http_started",
"backend" to backend,
"method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url),
)
}
return try {
chain.proceed(request).also { response ->
MembyDiagnostics.debug("http_finished", "backend" to backend, "method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url), "status" to response.code,
if (MembyDiagnostics.debugEnabled) {
MembyDiagnostics.debug(
"http_finished",
"backend" to backend,
"method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url),
"status" to response.code,
"duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
"correlation" to response.header("X-Memby-Correlation"))
"correlation" to response.header("X-Memby-Correlation"),
)
}
}
} catch (error: Exception) {
MembyDiagnostics.debug("http_failed", "backend" to backend, "method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url), "duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
"exception" to error.javaClass.simpleName, "detail" to error.message)
if (MembyDiagnostics.debugEnabled) {
MembyDiagnostics.debug(
"http_failed",
"backend" to backend,
"method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url),
"duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
"exception" to error.javaClass.simpleName,
"detail" to error.message,
)
}
throw error
}
}
@@ -10,6 +10,15 @@ object MembyDiagnostics {
private val rank = mapOf("TRACE" to 0, "DEBUG" to 1, "INFO" to 2)
private val configured = rank[BuildConfig.DIAGNOSTIC_LOG_LEVEL] ?: 2
/**
* Lets hot call sites avoid constructing vararg pairs and sanitised URLs when their
* configured level would discard the event. Network interceptors run for every API
* request, including playback progress, so doing that work before [write] can reject it
* turns disabled diagnostics into steady allocation pressure.
*/
val debugEnabled: Boolean get() = configured <= (rank["DEBUG"] ?: 1)
val traceEnabled: Boolean get() = configured <= (rank["TRACE"] ?: 0)
fun debug(event: String, vararg fields: Pair<String, Any?>) = write("DEBUG", event, fields)
fun trace(event: String, vararg fields: Pair<String, Any?>) = write("TRACE", event, fields)
fun info(event: String, vararg fields: Pair<String, Any?>) = write("INFO", event, fields)
@@ -8,10 +8,17 @@ import androidx.metrics.performance.JankStats
/** Debug-only frame telemetry. It does not alter rendering or app state. */
object PerformanceMonitor {
private const val TAG = "EmbyClientPerf"
private const val WINDOW_FRAMES = 120
private const val FRAME_60_FPS_NS = 16_666_667L
private const val TWO_FRAMES_60_FPS_NS = FRAME_60_FPS_NS * 2
private var stats: JankStats? = null
private var frameCount = 0
private var jankCount = 0
private var totalFrameMs = 0L
private var missedFrameBudgetCount = 0
private var missedTwoFrameBudgetCount = 0
private var totalFrameNanos = 0L
private var maxFrameNanos = 0L
private val frameDurations = LongArray(WINDOW_FRAMES)
private var windowStartedAt = 0L
fun start(activity: Activity) {
@@ -20,10 +27,18 @@ object PerformanceMonitor {
if (stats != null) return@post
windowStartedAt = SystemClock.elapsedRealtime()
stats = JankStats.createAndTrack(activity.window) { frameData ->
val duration = frameData.frameDurationUiNanos
frameDurations[frameCount] = duration
frameCount++
totalFrameMs += frameData.frameDurationUiNanos / 1_000_000L
totalFrameNanos += duration
maxFrameNanos = maxOf(maxFrameNanos, duration)
if (duration > FRAME_60_FPS_NS) missedFrameBudgetCount++
if (duration > TWO_FRAMES_60_FPS_NS) missedTwoFrameBudgetCount++
if (frameData.isJank) jankCount++
if (frameCount % 120 == 0) report("window")
if (frameCount == WINDOW_FRAMES) {
report("window")
resetWindow()
}
}
Log.i(TAG, "tracking started")
}
@@ -32,18 +47,50 @@ object PerformanceMonitor {
fun mark(name: String) {
if (stats == null) return
report(name)
resetWindow()
}
private fun resetWindow() {
frameCount = 0
jankCount = 0
totalFrameMs = 0
missedFrameBudgetCount = 0
missedTwoFrameBudgetCount = 0
totalFrameNanos = 0L
maxFrameNanos = 0L
windowStartedAt = SystemClock.elapsedRealtime()
}
private fun report(name: String) {
if (frameCount == 0) return
val elapsed = SystemClock.elapsedRealtime() - windowStartedAt
// One small copy every 120 debug frames is preferable to allocating or maintaining
// an ordered collection inside the frame callback itself.
val ordered = frameDurations.copyOf(frameCount).apply { sort() }
val p50 = ordered.percentile(50)
val p95 = ordered.percentile(95)
val p99 = ordered.percentile(99)
Log.i(
TAG,
"$name frames=$frameCount jank=$jankCount avgUiMs=${totalFrameMs / frameCount} elapsedMs=$elapsed",
"$name frames=$frameCount jank=$jankCount " +
"jankPct=${percent(jankCount, frameCount)} " +
"over16ms=$missedFrameBudgetCount over33ms=$missedTwoFrameBudgetCount " +
"avgUiMs=${nanosToTenths(totalFrameNanos / frameCount)} " +
"p50UiMs=${nanosToTenths(p50)} p95UiMs=${nanosToTenths(p95)} " +
"p99UiMs=${nanosToTenths(p99)} maxUiMs=${nanosToTenths(maxFrameNanos)} " +
"elapsedMs=$elapsed",
)
}
private fun LongArray.percentile(percentile: Int): Long {
val index = (((size - 1) * percentile) / 100).coerceIn(indices)
return this[index]
}
private fun nanosToTenths(nanos: Long): String =
"${nanos / 1_000_000}.${(nanos % 1_000_000) / 100_000}"
private fun percent(count: Int, total: Int): String {
val tenths = count * 1_000 / total
return "${tenths / 10}.${tenths % 10}"
}
}
@@ -160,6 +160,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null
private var detailPrefetchJob: Job? = null
private var refreshJob: Job? = null
private var forYouJob: Job? = null
private var forYouRequestId = 0L
@@ -170,6 +171,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
/** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics()
private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty())
@Volatile private var analyticsPausedForPlayback = false
init {
refreshAll()
@@ -200,11 +202,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* screen stops, so time spent sitting on one row is not lost.
*/
fun flushAnalytics() {
if (analyticsPausedForPlayback) return
analytics.endFocus()
repository.reportRowEvents(analytics.drain())
repository.reportJourneyEvents(journey.drain())
}
/**
* Closes the current dwell measurement without starting telemetry requests beside the
* player's decoder and first media reads. The buffered events are uploaded after the
* player returns; losing them if the process dies meanwhile is an acceptable telemetry
* trade, and is preferable to delaying the first picture.
*/
fun pauseAnalyticsForPlayback() {
analytics.endFocus()
analyticsPausedForPlayback = true
}
fun resumeAnalyticsAfterPlayback() {
analyticsPausedForPlayback = false
flushAnalytics()
}
fun trackJourney(
category: String, action: String, screen: String = "", feature: String = "",
source: String = "", target: String = "", itemName: String = "", itemType: String = "",
@@ -227,16 +246,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
requestForYou(availableMinutes, clearFocus = true)
}
/**
* Warms the dedicated destination once Home has its essential rows. Starting this
* before the viewer opens For You hides the gateway round trip without making the
* launcher's first response compete with another request.
*/
private fun preloadForYou(availableMinutes: Int) {
if (_forYou.value.rows.isNotEmpty()) return
requestForYou(availableMinutes, clearFocus = false)
}
private fun requestForYou(availableMinutes: Int, clearFocus: Boolean) {
val minutes = availableMinutes.coerceIn(0, 360)
if (forYouJob?.isActive == true && _forYou.value.availableMinutes == minutes) {
@@ -320,7 +329,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// Recommendation rows are built in the background by the gateway,
// so an early response can arrive without them. Keeping the rows
// we already had stops the strip flickering out and back in.
rows = taggedHome.rows.sanitisedRows().ifEmpty { current.rows },
rows = mergeFreshHomeRows(current.rows, taggedHome.rows),
loading = emptySet(),
hasRefreshError = taggedHome.partial,
statusMessage = null,
@@ -332,11 +341,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem)
}
preloadForYou(repository.currentSettings.forYouMinutes)
// Home may have been cached just before the background recommendation
// build completed. Pull the dedicated endpoint after the fast home draw
// so personalized Shows shelves appear on this visit, not a minute later.
refreshRecommendationRows()
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
@@ -352,31 +356,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
private suspend fun refreshRecommendationRows() {
val fresh = runCatching { repository.getRecommendations() }
.getOrElse { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
return
}
_state.update { state ->
val airingTodayKeys = state.rows.airingTodayShowKeys()
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
// The three id shapes are what the recommendation build is *expected* to
// replace. Anything else it returns under an id the launcher already holds is
// still a replacement — and, left alone, would be a second LazyColumn item
// under one key, which is a crash rather than a duplicate row. So the arriving
// set wins by id, and the concatenation is sanitised regardless.
val freshIds = taggedFresh.mapTo(mutableSetOf(), HomeRow::id)
val fixedRows = state.rows.filterNot { row ->
row.id == "recommended" ||
row.id.startsWith("similar:") ||
row.id.startsWith("curated:") ||
row.id in freshIds
}
state.copy(rows = (fixedRows + taggedFresh).sanitisedRows())
}
}
/**
* Updates local metadata immediately, then enriches it only after focus settles.
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
@@ -386,19 +365,10 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val focused = focusedItemWithMetadata(item, cached)
_focusedItem.value = focused
metadataJob?.cancel()
detailPrefetchJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// Do not negotiate playback on focus. Emby creates a playback session as
// part of PlaybackInfo, so warming a stream here made merely browsing a
// shelf appear in server history as something the viewer had played.
// Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later.
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
launch { runCatching { repository.getRelated(focused) } }
}
if (cached == null && !item.isSchedule) {
launch {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
@@ -409,17 +379,17 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
}
launch { warmDetailPage(item) }
}
detailPrefetchJob = viewModelScope.launch(Dispatchers.IO) {
delay(DETAIL_PREFETCH_DELAY_MS)
warmDetailPage(focused)
}
}
/**
* The two requests a detail page still opened cold, warmed while the card is focused.
*
* Everything else the page needs is already in hand by the time it opens the item
* record and its "why you might enjoy it" are warmed above but
* the episode list and the trailer were not, so a series page opened with an empty
* The item record is warmed separately for the launcher's metadata panel. The larger
* detail-only requests wait here, so a series page does not open with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page
* opened with its trailer button missing until the network answered. Continue Watching
* is the case that matters most: every card on the launcher's busiest row is an
@@ -436,8 +406,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/
private suspend fun warmDetailPage(item: BaseItem) {
if (item.isSchedule) return
delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// Related, episodes and trailers are detail-page work. Waiting until focus has
// genuinely settled prevents a held D-pad from starting long-lived requests
// for every card it crosses; a press still shares the resulting single flight.
if (item.isMovie || item.isSeries) {
launch { runCatching { repository.getRelated(item) } }
}
// A series is keyed on itself, an episode on the show it belongs to — which is
// exactly what its own detail page will ask for.
val seriesId = when {
@@ -623,12 +598,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
override fun onCleared() {
analyticsPausedForPlayback = false
flushAnalytics()
super.onCleared()
}
companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L
/**
* How long focus must rest on a card before its detail page is warmed, measured
@@ -638,7 +614,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* this one can be a thousand episode records and should only follow a viewer who
* has stopped. See [warmDetailPage].
*/
private const val DETAIL_PREFETCH_DELAY_MS = 450L
private const val DETAIL_PREFETCH_DELAY_MS = 500L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val HOME_RETRY_INITIAL_MS = 2_000L
private const val HOME_RETRY_MAX_MS = 60_000L
@@ -667,6 +643,32 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
)
/**
* Applies a live launcher response without making cached recommendation shelves disappear
* while the gateway rebuilds its recommendation cache in the background.
*
* A fresh engine set is authoritative and replaces the old one as a group. Prepared
* `for-you:` rows are intentionally not used as that signal: they are assembled separately
* by the gateway and may be present while the engine cache is still cold.
*/
internal fun mergeFreshHomeRows(
previous: List<HomeRow>,
incoming: List<HomeRow>,
): List<HomeRow> {
val fresh = incoming.sanitisedRows()
if (fresh.isEmpty()) return previous.sanitisedRows()
if (fresh.any(HomeRow::isEngineRecommendationRow)) return fresh
val freshIds = fresh.mapTo(mutableSetOf(), HomeRow::id)
val retainedRecommendations = previous.filter { row ->
row.isEngineRecommendationRow() && row.id !in freshIds
}
return (fresh + retainedRecommendations).sanitisedRows()
}
private fun HomeRow.isEngineRecommendationRow(): Boolean =
id == "recommended" || id.startsWith("similar:") || id.startsWith("curated:")
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
@@ -56,6 +56,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.key
import androidx.compose.runtime.withFrameNanos
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
@@ -109,6 +110,8 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.imageLoader
@@ -251,17 +254,32 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
)
class MainActivity : ComponentActivity() {
private var homeInteractiveReported = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Captured before composition and never observed as state: a downloaded document
// is for the next process, never a label change under the viewer's focus.
val remoteConfig = ServiceLocator.remoteConfig.active
setContent {
MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) }
MembyTheme {
AppRoot(
remoteConfig = remoteConfig,
onCloseSettings = ::finish,
onHomeInteractive = ::reportHomeInteractive,
)
}
}
PerformanceMonitor.start(this)
}
private fun reportHomeInteractive() {
if (homeInteractiveReported) return
homeInteractiveReported = true
reportFullyDrawn()
PerformanceMonitor.mark("home_interactive")
}
override fun onStart() {
super.onStart()
// Also runs when playback returns to Home. A used preroll player is parked while
@@ -308,7 +326,11 @@ private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
@Composable
private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) {
private fun AppRoot(
remoteConfig: MembyRemoteConfig,
onCloseSettings: () -> Unit,
onHomeInteractive: () -> Unit,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active
@@ -655,6 +677,13 @@ private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit
key(loaded.userId, loaded.serverUrl) {
HomeScreen(settings = loaded, remoteConfig = remoteConfig)
}
LaunchedEffect(loaded.userId, loaded.serverUrl) {
// Report after the launcher has submitted a frame, not merely when its
// composable was entered. StartupTimingMetric can now distinguish the
// quick opening surface from the point at which D-pad content is live.
withFrameNanos { }
onHomeInteractive()
}
// Snow, bats or blossom for the few days a year a season is on, over the
// launcher and nowhere else. Not over playback — a film is the one thing
// nothing may drift across — and not over the settings sheet, which is a
@@ -2021,6 +2050,7 @@ private fun HomeScreen(
category = "playback", action = "stop", screen = "player",
feature = "playback", target = selectedDestination.name.lowercase(),
)
homeViewModel.resumeAnalyticsAfterPlayback()
// PlayerActivity has finished and this activity owns the window again. Compose
// needs one frame to reattach the saved card's focus node before it can receive
// focus, especially when playback progress refreshed the row behind the player.
@@ -2030,20 +2060,26 @@ private fun HomeScreen(
scope.launch {
myShowsLoading = true
notificationsLoading = true
kotlinx.coroutines.coroutineScope {
launch {
runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
myShowsLoading = false
}
launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
myShowsLoading = false
notificationsLoading = false
}
}
kotlinx.coroutines.delay(32L)
// Trailer playback leaves its detail page composed. Let Compose restore the
// exact hero action instead of moving focus to the home card behind it.
@@ -2063,21 +2099,27 @@ private fun HomeScreen(
LaunchedEffect(settings.userId) {
myShowsLoading = true
notificationsLoading = true
kotlinx.coroutines.coroutineScope {
launch {
runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
myShowsLoading = false
}
launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
myShowsLoading = false
notificationsLoading = false
}
}
}
LaunchedEffect(liveMaintenance) {
if (liveMaintenance != null) {
@@ -2106,6 +2148,7 @@ private fun HomeScreen(
resolveJob = null
resolvingItem = null
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
val playItem: (BaseItem) -> Unit = playItem@{ item ->
if (launchingItem != null || !item.membyPlayable) return@playItem
@@ -2114,7 +2157,7 @@ private fun HomeScreen(
feature = "playback", source = returnRowId.orEmpty(), target = "player",
itemName = item.name, itemType = item.type,
)
homeViewModel.flushAnalytics()
homeViewModel.pauseAnalyticsForPlayback()
launchingItem = item
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
// Resuming: open the player now and let it resolve the stream while it starts.
@@ -2133,6 +2176,7 @@ private fun HomeScreen(
}.getOrElse {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
return@playItem
}
val request = prepared.first
@@ -2151,6 +2195,7 @@ private fun HomeScreen(
if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
return@playItem
}
@@ -2202,6 +2247,7 @@ private fun HomeScreen(
if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
}
.onFailure { error ->
@@ -2211,6 +2257,7 @@ private fun HomeScreen(
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
// Nothing was launched, so nothing will come back to reopen the gate.
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
} finally {
resolvingItem = null
@@ -2226,7 +2273,7 @@ private fun HomeScreen(
feature = "trailer", source = "details", target = "player",
itemName = item.name, itemType = item.type,
)
homeViewModel.flushAnalytics()
homeViewModel.pauseAnalyticsForPlayback()
val launched = runCatching {
playbackLauncher.launch(
PlayerActivity.trailerIntent(
@@ -2242,6 +2289,7 @@ private fun HomeScreen(
}
if (launched.isFailure) {
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
Toast.makeText(context, "Couldnt open the trailer", Toast.LENGTH_SHORT).show()
}
}
@@ -4027,14 +4075,15 @@ private fun HomeArtworkPreloader(
availableWidth: androidx.compose.ui.unit.Dp,
) {
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val density = LocalDensity.current
val repo = ServiceLocator.repository
val discovered = remember(rows) {
// Warm the leading posters across several rows instead of exhausting the
// budget on the first shelf. Vertical navigation is then far less likely to
// compete with image fetch/decode as the next row enters the viewport.
val perRow = rows.map { row ->
row.items.take(4).map { row.kind to it }
val perRow = rows.take(6).map { row ->
row.items.take(2).map { row.kind to it }
}
buildList {
val depth = perRow.maxOfOrNull { it.size } ?: 0
@@ -4045,12 +4094,14 @@ private fun HomeArtworkPreloader(
}
}
.distinctBy { it.second.id }
.take(24)
.take(12)
}
val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } }
LaunchedEffect(signature, availableWidth) {
// Let visible cards win the first network/decode slots, then warm everything
// else that this home response discovered into Coil's memory and disk caches.
LaunchedEffect(signature, availableWidth, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Let visible cards win the first network/decode slots. Limiting the warm to
// the leading pair from the next six shelves covers the likely D-pad path
// without decoding a second screenful nobody may visit.
kotlinx.coroutines.delay(350L)
discovered.forEach { (kind, item) ->
val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode
@@ -4077,6 +4128,7 @@ private fun HomeArtworkPreloader(
}
}
}
}
@Composable
private fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -41,4 +42,66 @@ class HomeUiStateTest {
state.continueWatching.map(BaseItem::id),
)
}
@Test
fun coldRecommendationResponseKeepsCachedShelves() {
val previous = listOf(
row("continue", "resume"),
row("recommended", "old-pick"),
row("curated:comedy", "old-comedy"),
)
val incoming = listOf(
row("continue", "fresh-resume"),
row("latest", "fresh-film"),
)
val merged = mergeFreshHomeRows(previous, incoming)
assertEquals(
listOf("continue", "latest", "recommended", "curated:comedy"),
merged.map(HomeRow::id),
)
assertEquals("fresh-resume", merged.first().items.single().id)
}
@Test
fun freshRecommendationSetReplacesCachedSet() {
val previous = listOf(
row("continue", "resume"),
row("recommended", "old-pick"),
row("curated:comedy", "old-comedy"),
)
val incoming = listOf(
row("continue", "fresh-resume"),
row("recommended", "fresh-pick"),
)
val merged = mergeFreshHomeRows(previous, incoming)
assertEquals(listOf("continue", "recommended"), merged.map(HomeRow::id))
assertEquals("fresh-pick", merged.last().items.single().id)
}
@Test
fun preparedForYouRowDoesNotDiscardCachedEngineShelves() {
val previous = listOf(row("recommended", "old-pick"))
val incoming = listOf(
row("continue", "resume"),
row("for-you:quick", "quick-pick"),
)
val merged = mergeFreshHomeRows(previous, incoming)
assertEquals(
listOf("continue", "for-you:quick", "recommended"),
merged.map(HomeRow::id),
)
}
private fun row(id: String, itemId: String) = HomeRow(
id = id,
title = id,
kind = id.substringBefore(':'),
items = listOf(BaseItem(id = itemId)),
)
}
@@ -27,8 +27,23 @@ class HomeBenchmark {
@Test
fun coldStartToHomeWithProfile() = measureColdStart(CompilationMode.Partial())
/** Returning to Memby while its process is still resident on the television. */
@Test
fun warmStartToHomeWithProfile() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
compilationMode = CompilationMode.Partial(),
startupMode = StartupMode.WARM,
iterations = 5,
setupBlock = { pressHome() },
measureBlock = { startActivityAndWait() },
)
private fun measureColdStart(mode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
// MainActivity calls reportFullyDrawn after Home has submitted its first D-pad-ready
// frame. StartupTimingMetric therefore reports both the opening surface and actual
// time-to-interactive instead of treating the branded intro as a finished launch.
metrics = listOf(StartupTimingMetric()),
compilationMode = mode,
startupMode = StartupMode.COLD,
+1 -1
View File
@@ -1 +1 @@
0.1.48
0.1.49
+10 -4
View File
@@ -79,10 +79,13 @@ viewers is enough only when both rolled into the next episode, which is unambigu
they left.
**Visual** scanning finds a sustained structural transition — dark, flat, textured with thin
text, and staying that way for a minute. Darkness is *multiplied* rather than added into the
frame score, which is the one modelling decision worth defending: under a weighted sum a
night exterior reaches the credit-like floor on darkness and flatness alone, which is exactly
how a final scene comes to be reported as a credits roll. A unit test pins that case.
text, and staying that way for about half a minute. Darkness is *multiplied* rather than added
into the frame score, which is the one modelling decision worth defending: under a weighted
sum a night exterior reaches the credit-like floor on darkness and flatness alone, which is
exactly how a final scene comes to be reported as a credits roll. The candidate frame itself
must also be genuinely dark, and at least 65% of the remaining frames must stay credit-like;
those two guards stop a dim final scene or a temporary title card becoming an early marker.
Unit tests pin both cases.
The two fail in unrelated ways, so agreement between them is worth far more than either
alone — hence a probabilistic union rather than an average. Disagreement beyond 20 seconds
@@ -163,3 +166,6 @@ in which demand-driven narrowing is doing nothing, and the design would need rev
on every playback.** Readings wobble by seconds; a ±12s difference is not news.
- **The detector never learns why an episode was chosen.** That boundary is what stops it
being tuned to agree with the predictor rather than with the media.
- **An `ffmpeg` decoder crash gets one conservative retry.** The retry is single-threaded and
discards corrupt packets; ordinary network, authentication and timeout failures are not
retried by the sampler.
+53 -13
View File
@@ -20,9 +20,12 @@ import (
// proportion to a feature whose fallback is simply not showing a button.
const (
// sustainSeconds is how long the credit-like state has to persist to count. Shorter than
// this and a dark establishing shot at the end of an act qualifies.
sustainSeconds = 45
// sustainSeconds is how long the credit-like state has to persist to count. Thirty
// seconds still rejects an ordinary fade or end-of-act beat, but admits the compact
// closing rolls common to half-hour and network television. It also fits inside the
// thirty-second forward half of the fine pass; the old forty-five-second requirement
// made refinement around a correctly centred transition impossible.
sustainSeconds = 30
// leadSeconds is how much ordinary programme has to precede the transition. Without it
// the changepoint can sit at the very first sampled frame, which is not evidence of a
@@ -34,10 +37,23 @@ const (
// "slightly more dark" in the middle of a night scene.
creditLikeFloor = 0.45
// onsetMeanCeiling requires the candidate cut itself to be genuinely dark. A dim final
// scene can share the roll's edge density and be followed by real credits soon enough to
// lift the sustain average; accepting it clips the scene. The detector deliberately does
// not claim bright or picture-backed credits without behavioural corroboration.
onsetMeanCeiling = 0.10
// minSeparation is how much better the tail has to score than the head. This is the
// primary guard against answering on noise, and it is set high because the cost of a
// wrong marker is somebody losing the end of an episode.
minSeparation = 0.18
// primary guard against answering on noise. A dark programme can make the absolute gap
// modest even at its real credits; the stay-credit-like guard below is what makes this
// lower bar safe.
minSeparation = 0.10
// minCreditLikeTailFraction says that credits continue to the end of the file. A dark
// scene may satisfy the short sustain window, but ordinary programme resumes afterwards;
// a real roll leaves most remaining samples credit-like even with black gaps, logos and
// production cards mixed through it.
minCreditLikeTailFraction = 0.65
// onsetFraction is how credit-like a frame has to be, relative to the established roll,
// to count as already part of it.
@@ -142,13 +158,17 @@ func (d *VisualDetector) Detect(ctx context.Context, media MediaInfo) (Detection
func creditScore(frame frameStats) float64 {
darkness := frame.DarkFraction
// Text produces a narrow *band* of edge density: a flat black frame has almost none, and
// a detailed photograph has far more than titles do. Scoring the band rather than the
// magnitude is what stops a bright, busy scene outscoring the credits.
const idealEdges = 0.030
// Text produces a band of edge density: a flat black frame has almost none, and a
// detailed photograph has far more than titles do. The useful band is deliberately
// broad. A sparse title card and a dense cast roll are both credits; the old triangle
// reached zero at twice one narrow ideal and rejected the latter outright.
const (
idealEdges = 0.020
maxEdges = 0.120
)
text := frame.EdgeDensity / idealEdges
if text > 1 {
text = 2 - text
text = 1 - (frame.EdgeDensity-idealEdges)/(maxEdges-idealEdges)
}
text = clamp01(text)
@@ -190,8 +210,13 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
// a few hundred frames, but the quadratic version is the kind of thing that stops being
// free the moment somebody widens the window.
prefix := make([]float64, len(scores)+1)
qualifying := make([]int, len(scores)+1)
for index, score := range scores {
prefix[index+1] = prefix[index] + score
qualifying[index+1] = qualifying[index]
if score >= creditLikeFloor {
qualifying[index+1]++
}
}
segmentMean := func(from, to int) float64 {
if to <= from {
@@ -202,6 +227,9 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
bestIndex, bestSeparation, found := 0, 0.0, false
for split := lead; split+sustain <= len(frames); split++ {
if frames[split].Mean > onsetMeanCeiling {
continue
}
head := segmentMean(0, split)
tail := segmentMean(split, len(frames))
// The sustained window immediately after the split has to qualify on its own, not
@@ -210,6 +238,11 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
if segmentMean(split, split+sustain) < creditLikeFloor {
continue
}
tailCount := len(frames) - split
creditLikeTail := qualifying[len(scores)] - qualifying[split]
if float64(creditLikeTail)/float64(tailCount) < minCreditLikeTailFraction {
continue
}
separation := tail - head
if separation < minSeparation || separation <= bestSeparation {
continue
@@ -222,8 +255,15 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
// The separation is reported for the split that earned it, never for the walked-back
// frame: confidence is a statement about how clearly the transition was found, and
// recomputing it over a fade would report the answer as weaker for having been improved.
return backOffToOnset(scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval),
bestSeparation, true
onset := backOffToOnset(
scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval,
)
// The score-only walk can step back onto a dim final picture whose texture resembles
// text. Preserve the same luminance guard that qualified the split itself.
for onset < bestIndex && frames[onset].Mean > onsetMeanCeiling {
onset++
}
return onset, bestSeparation, true
}
// backOffToOnset walks a split backwards through the credits' fade-in.
+75
View File
@@ -49,6 +49,19 @@ func darkSceneFrame(position time.Duration) frameStats {
}
}
// A dim, textured final scene: close enough to the roll's structural score that what comes
// after it could lift the sustain average, but visibly still programme.
func dimTexturedSceneFrame(position time.Duration) frameStats {
return frameStats{
PositionMs: position.Milliseconds(),
Mean: 0.13,
Variance: 0.006,
DarkFraction: 0.78,
EdgeDensity: 0.015,
Diff: 0.08,
}
}
func window(start time.Duration, kinds ...func(time.Duration) frameStats) []frameStats {
frames := make([]frameStats, 0, len(kinds))
for index, build := range kinds {
@@ -85,6 +98,32 @@ func TestFindsACleanTransition(t *testing.T) {
}
}
// Television rolls are commonly only half a minute long. This is also the amount of media
// available after a correctly centred transition in the fine pass, so requiring more would
// make the refinement structurally unable to confirm the coarse answer.
func TestFindsACompactTelevisionCreditsRoll(t *testing.T) {
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, creditsFrame)...)
frames := window(20*time.Minute, kinds...)
if index, _, found := findTransition(frames, coarseInterval); !found || index != 20 {
t.Fatalf("compact credits transition = %d, found %t; want frame 20", index, found)
}
}
func TestFinePassCanConfirmACentredTransition(t *testing.T) {
frames := make([]frameStats, 0, 80)
for index := 0; index < 40; index++ {
frames = append(frames, programmeFrame(time.Duration(index)*fineInterval))
}
for index := 40; index < 80; index++ {
frames = append(frames, creditsFrame(time.Duration(index)*fineInterval))
}
if index, _, found := findTransition(frames, fineInterval); !found || index != 40 {
t.Fatalf("fine transition = %d, found %t; want frame 40", index, found)
}
}
// The whole point of not answering on darkness alone.
func TestDarkSceneIsNotCredits(t *testing.T) {
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, darkSceneFrame)...)
@@ -122,6 +161,34 @@ func TestBriefDarkBeatIsIgnored(t *testing.T) {
}
}
// A locally convincing title-like sequence in the middle of the tail is not closing
// credits when ordinary programme resumes after it. This is the real-media failure shape:
// looking only at the immediate sustain window marked Westworld and Blue Bloods several
// minutes before their genuine rolls.
func TestCreditLikeSequenceThatDoesNotReachTheEndIsIgnored(t *testing.T) {
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, creditsFrame)...)
kinds = append(kinds, repeatFrames(20, programmeFrame)...)
frames := window(35*time.Minute, kinds...)
if _, _, found := findTransition(frames, coarseInterval); found {
t.Fatal("a temporary credit-like sequence was reported as closing credits")
}
}
func TestDimFinalSceneBeforeCreditsDoesNotMoveTheMarkerEarly(t *testing.T) {
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, dimTexturedSceneFrame)...)
kinds = append(kinds, repeatFrames(12, creditsFrame)...)
frames := window(35*time.Minute, kinds...)
index, _, found := findTransition(frames, coarseInterval)
if !found {
t.Fatal("the genuine credits after the dim scene were not found")
}
if index != 28 {
t.Fatalf("transition at frame %d, want the credits at frame 28", index)
}
}
func TestTooFewFramesAnswerNothing(t *testing.T) {
frames := window(41*time.Minute, repeatFrames(4, creditsFrame)...)
if _, _, found := findTransition(frames, coarseInterval); found {
@@ -151,6 +218,14 @@ func TestCreditScoreSeparatesTheThreeCases(t *testing.T) {
}
}
func TestDenseCreditsStillLookLikeCredits(t *testing.T) {
frame := creditsFrame(0)
frame.EdgeDensity = 0.075
if score := creditScore(frame); score < creditLikeFloor {
t.Fatalf("dense credits scored %.2f, below the floor %.2f", score, creditLikeFloor)
}
}
// Confidence from one detector agreeing with itself is not corroboration.
func TestVisualConfidenceIsCapped(t *testing.T) {
if score := visualConfidence(10); score >= 1 {
+49 -1
View File
@@ -122,12 +122,42 @@ func (s *Sampler) Sample(
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
stats, err := s.samplePass(ctx, url, from, to, interval, false)
if err == nil || !decoderCrashed(err) || ctx.Err() != nil {
return stats, err
}
// A decoder crash is local to the ffmpeg process, not evidence that the media is
// unreadable. Retry once with conservative decoder settings: single-threaded decoding
// avoids the most common native-code race, while corrupt packets are discarded rather
// than handed back through the failing path. Ordinary HTTP and authentication failures
// are never retried here.
stats, retryErr := s.samplePass(ctx, url, from, to, interval, true)
if retryErr != nil {
return stats, fmt.Errorf("credits: conservative ffmpeg retry: %w", retryErr)
}
return stats, nil
}
func (s *Sampler) samplePass(
ctx context.Context, url string, from, to time.Duration, interval time.Duration,
conservative bool,
) ([]frameStats, error) {
// -ss ahead of -i is the whole optimisation: it seeks in the container before opening a
// decoder, so the input starts near the credits. Behind -i it would decode from zero and
// discard, which is the full read this package exists to avoid.
args := []string{
"-hide_banner", "-loglevel", "error", "-nostdin",
"-ss", formatSeconds(from),
}
if conservative {
args = append(args,
"-threads", "1",
"-fflags", "+discardcorrupt",
"-err_detect", "ignore_err",
)
}
args = append(args,
"-i", url,
"-t", formatSeconds(to-from),
"-an", "-sn", "-dn",
@@ -136,7 +166,7 @@ func (s *Sampler) Sample(
"-frames:v", strconv.Itoa(maxFrames),
"-f", "rawvideo", "-pix_fmt", "gray",
"pipe:1",
}
)
cmd := exec.CommandContext(ctx, s.binary(), args...)
// Cancel and WaitDelay together are what stop an orphan. CommandContext's default is to
@@ -181,6 +211,24 @@ func (s *Sampler) Sample(
return stats, nil
}
func decoderCrashed(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, signature := range []string{
"segmentation fault",
"signal: aborted",
"signal: bus error",
"access violation",
} {
if strings.Contains(message, signature) {
return true
}
}
return false
}
// readFrames pulls fixed-size grayscale frames off the pipe and reduces each one as it
// arrives. The frame buffer is allocated once and reused, so a four-hundred-frame pass
// allocates fourteen kilobytes rather than five and a half megabytes.
+28
View File
@@ -0,0 +1,28 @@
package credits
import (
"errors"
"testing"
)
func TestDecoderCrashClassification(t *testing.T) {
for _, message := range []string{
"credits: ffmpeg: signal: segmentation fault",
"credits: ffmpeg: signal: aborted",
"credits: ffmpeg: signal: bus error",
"credits: ffmpeg: access violation reading location",
} {
if !decoderCrashed(errors.New(message)) {
t.Errorf("%q was not classified as a decoder crash", message)
}
}
for _, message := range []string{
"credits: ffmpeg: exit status 1: HTTP error 401 Unauthorized",
"context deadline exceeded",
"credits: ffmpeg is not available",
} {
if decoderCrashed(errors.New(message)) {
t.Errorf("%q was classified as a decoder crash", message)
}
}
}