This commit is contained in:
ponzischeme89
2026-08-18 08:41:48 +12:00
parent 1da91e40a1
commit 36d171e51b
50 changed files with 4972 additions and 377 deletions
+113
View File
@@ -488,6 +488,55 @@ behind it. Things to preserve:
enabling the scanner would announce every show that had already ended as new news, and
without durable history a gateway restart could announce the same change again.
**Watch time is Tracearr's, and it is never counted twice.** `store.watchedMsExpr` in
`internal/store/watch_time.go` is the one definition of "how long was this actually watched"
— the greater of Tracearr's `durationMs` and `progressMs`, capped at the title's own length —
and the console's figure and the viewer's summary are both queries over `tracearr_sessions`
rather than a second table of minutes. A table counting watching separately would be a copy
of a copy, wrong the moment Tracearr corrects a session. It is read two ways:
- **The console reads it beside the person.** `/admin/api/accounts` carries a `watchTime`
per account — week, month, lifetime, and when they last watched — from one grouped query
for the whole household, because that page grows with the family. `matched` is the
load-bearing field: a household running no Tracearr and a viewer Tracearr has never seen
both arrive as zeroes, and a console drawing those as "0 min this week" would have an
operator investigating a person rather than an integration. A watch-time read that fails
costs the figures and never the account list.
- **`attributeWatchTime` joins the two systems on the username**, which is the identity they
genuinely share, and prefers the Tracearr id `recommendation_user_profiles` recorded where
there is one — so a viewer renamed in one system keeps their figures instead of silently
reporting zero. It is pure, so the console and the digest cannot attribute the same rows
differently.
- **The weekly summary is a personal notification, not a service alert.** A service alert is
the house being told something; how long somebody watched is nobody else's news, so it
lands in My Alerts (`watch-time-week` / `watch-time-month`, which an app that predates them
renders with the fallback icon) and follows the person to every set. `RegisterWatchTimeTasks`
registers it as an ordinary scheduler job, so an operator can see when it last ran and send
one by hand — which for a job that fires once a week is the difference between "it has sent
nothing" and "it has not run".
- **The source key is the only thing preventing a repeat.** It runs hourly and sends from the
appointed hour to the end of that day, because `watch-time:weekly:2026-W33` is written
`ON CONFLICT DO NOTHING`: a container restarted three times on a Sunday evening delivers one
summary, and a gateway that was off all evening still delivers it the next hour it is up.
The monthly summary is the same trick over a `YYYY-MM` key, which is why it catches up
rather than being skipped for ever by a gateway that was down on the first.
- **Sunday evening, not Monday morning**, because the figure sent is week-to-date: on a Monday
it would summarise almost nothing. Every boundary is a household-local *calendar* date
(`weekStartIn`, `monthStartIn`, `previousMonth`), never `now.Add(-7*24*time.Hour)` — a week
containing a daylight-saving change is 23 or 25 hours short or long, and subtracting hours
puts the boundary an hour inside the previous Sunday twice a year. `watch_time_test.go` pins
the clock-change week.
- **Two switches, and they answer different questions.** `watch_time_digest` in the
`featureCatalogue` is the household's and carries no capability — nothing on the television
has to understand this. `NotificationPreferences.WatchTimeDigest` is the viewer's own, kept
apart from `SystemAlerts` because this is the only notification there that is about *them*.
Turning it off also withdraws the summaries already sitting in their list
(`filterStoredNotifications`): switching a weekly notice off is a statement about the ones
already there as much as about the next one.
- **Nothing under `watchTimeDigestFloor` is sent**, and a preference that will not load is
read as "not now" rather than as consent. A digest reporting four minutes is a notification
about a title somebody abandoned, and a feed carrying those is one nobody opens.
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` →
@@ -1424,6 +1473,70 @@ but the address. Four things to preserve:
than none, since it opens a connection nothing will use and leaves the one that matters
cold.
**An advance opens the next episode's stream before anybody asks for it.** `StreamWarmer`
warms the *host*; this warms the title, which it can only do here because an advance is the
one case where the app knows what is next minutes ahead — `NextUpResolver` has the answer
five minutes before the credits (`NEXT_UP_STREAM_WARM_LEAD_MS`). Media3's
`DefaultPreloadManager` does the work; `ui/player/NextEpisodePreloader.kt` owns the two
decisions it cannot make for itself, and `PreloadPlan.kt` holds them as pure functions so
they can be pinned by plain JUnit. Things to preserve:
- **The player and the manager are built from one builder.**
`DefaultPreloadManager.Builder.buildExoPlayer` *overwrites* the media source factory,
renderers, load control, bandwidth meter, track selector and playback looper on whatever
`ExoPlayer.Builder` it is handed, so everything shared is set on the manager's builder and
the player's carries only what the manager has no opinion about. The looper is the one
that would actually break: a source prepared on one playback thread and played on another
is a crash, not a slow start.
- **Ranking data is a position in the journey, not a playlist index.** Nothing here is a
playlist — the player is handed one episode at a time and the next is discovered while it
plays — so a rank is assigned when an answer arrives and only ever moves forward. Exactly
one episode ahead is preloaded (`preloadTargetFor`); two would double the cost for a
viewer who has two episodes' worth of time to walk away.
- **Eviction never touches the episode playing.** On an advance the player has just been
handed that episode's `MediaSource`, and `remove` releases the source underneath the
decoder using it. `obsoletePreloadRanks` is strictly-behind for that reason, `advanceTo`
is called *after* `startMedia` and not before it, and the entry for the playing episode is
left in the manager to be quietened by its target status turning to
`PRELOAD_STATUS_NOT_PRELOADED` on the next `invalidate`.
- **The bound is a memory ceiling first.** `PRELOAD_RANGE_MS` is five seconds because the
expensive half of starting a stream is the connection, the container header and the seek
index — which `specifiedRangeLoaded` pays by preparing the source and selecting tracks —
not the bytes. A 4K direct play runs past 30 Mbps, so every second held ahead is megabytes
on a box with none spare, for a title the viewer may not go on to.
- **Registration hangs off the resolver, not off one call site.** `NextUpResolver`'s
`onResolved` fires for the first lookup *and* for every re-negotiation of a stale stream,
and a re-negotiation is exactly when preloaded work stops matching the URL the player will
be handed. It fires only for the episode still playing, or an answer that arrived after
the viewer moved on would have the preloader open a connection for a journey that no
longer exists.
- **Every part of it degrades to what came before.** A manager that will not build leaves
the preloader unattached, `sourceFor` answers null and `startMedia` takes the ordinary
`setMediaItem` path — which is also what a cold start, the direct-to-Emby path, a retry
that re-negotiated, and an unfinished preload all take. That fallback is *logged*
(`event=preload_unavailable`), because it is invisible from the viewer's side and a set
that never preloads anything otherwise looks exactly like one where the feature works and
never happens to save time.
- **`PRELOADING_ENABLED` and `DYNAMIC_SCHEDULING_ENABLED` are separate switches** in
`PlayerEngine`, the `THEME_PICKER_ENABLED` precedent, so a television that misbehaves on
Media3's experimental scheduling can have that taken away without losing preloading or the
version bump underneath both.
- **`event=first_frame` says which start it is measuring** (`start=cold` / `start=preloaded`).
The whole feature is a claim about one of two latencies, and a log that could not separate
them could not show whether it worked. `event=preload_ready` carries how long the preload
itself took and the range it was bounded to.
**Media3 is one version across every artifact, and the Jellyfin FFmpeg extension pins which
one that can be.** That extension is compiled against `media3-exoplayer` and reached
*reflectively* through `EXTENSION_RENDERER_MODE_ON`, so a core from a different minor line
fails at renderer construction rather than at compile time — and `PlayerEngine`'s
`LinkageError` fallback would swallow it, silently withdrawing surround software decode with
nothing in the log to say why. Jellyfin publishes up to the 1.9 line, so `media3Version` in
`app/build.gradle.kts` is on it. `enablePerStreamMediaProgression` arrived in 1.11 and is
therefore not available here; `experimentalSetDynamicSchedulingEnabled` is the part of that
same work which is. Moving the core past 1.9 means finding a matching extension first, or
deciding to do without DTS.
**Playback position has one ordered exit path.** Ten-second progress updates, pause/seek
updates and the final Stop all pass through `EmbyRepository`'s `playbackReportMutex`, so a
slow older Progress request cannot complete after Stop and move Emby's saved playhead back.
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-CahtXjpP.js"></script>
<script type="module" crossorigin src="/admin/assets/index-d9286FJI.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-Cg_z5PGS.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-cNUhbl7V.css">
</head>
<body>
<div id="root"></div>
+28 -4
View File
@@ -25,6 +25,25 @@ export function duration(ms: number | undefined | null): string {
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
/** watchTime words a span of viewing, where `duration` words a span of machine time.
*
* Two formatters rather than one because they answer different questions: a request that
* took 1400ms wants its milliseconds, and an evening in front of the television does not
* it is measured in hours and minutes and rounds to the minute. Nothing under a minute is a
* figure at all, and zero says so in words rather than printing "0m", which reads as a
* reading that failed rather than as an evening off. The wording deliberately matches the
* gateway's own formatWatchDuration, so the console and the summary a viewer receives
* cannot describe the same week two ways. */
export function watchTime(ms: number | undefined | null): string {
const minutes = Math.round(Math.max(0, ms ?? 0) / 60000);
if (minutes <= 0) return 'none';
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (rest === 0) return hours === 1 ? '1 hour' : `${hours} hours`;
return `${hours}h ${rest}m`;
}
/** interval describes a schedule, where "3600s" is a worse answer than "every hour". */
export function interval(seconds: number | undefined | null): string {
if (!seconds || seconds <= 0) return 'on request only';
@@ -84,12 +103,17 @@ const IDLE_MS = 3 * 60 * 60 * 1000;
export const recent = (value: string | undefined | null): boolean =>
Boolean(value) && Date.now() - new Date(value as string).getTime() < ACTIVE_MS;
export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'note' | 'data';
export type Tone = 'ok' | 'idle' | 'warn' | 'bad' | 'info' | 'note' | 'data';
/* Three states rather than two, because "not active this minute" covers both a set
somebody switched off after breakfast and one that has not been seen since lunchtime
and only the second is worth an operator's attention. Green is on now, amber is a set
in ordinary use that happens to be off, and red is one that has stopped checking in for
and only the second is worth an operator's attention.
The middle state is the quiet green rather than amber. Amber is the console's "look at
this", and a television that checked in an hour ago is the ordinary condition of every
set in a house at any given moment: a page of amber dots every evening is a page that
teaches an operator to ignore the colour, which is exactly what it must not do on the
evening one of them really has stopped. Two shades of green say what is true both are
fine, one is connected right now and red is kept for a set that has not been seen for
three hours. A device with no timestamp at all is red: never seen is the strongest
version of not seen. */
export function presence(value: string | undefined | null): { tone: Tone; label: string } {
@@ -97,7 +121,7 @@ export function presence(value: string | undefined | null): { tone: Tone; label:
if (!seen) return { tone: 'bad', label: 'never seen' };
const age = Date.now() - seen;
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
if (age < IDLE_MS) return { tone: 'idle', label: 'seen recently' };
return { tone: 'bad', label: 'not seen lately' };
}
+570
View File
@@ -0,0 +1,570 @@
import type { LogEvent } from '../api/types';
/* The shape of a server event, for a table rather than for a file.
*
* The gateway writes structured records a message and a bag of attributes and the old
* page rendered the bag as `component=admin client=unknown method=GET path=… status=200`,
* which is every fact and no hierarchy. A person reading a log is asking four questions in
* order: when, which part of the server, what happened, did it work. This module answers
* them, so the table can print the answers and the drawer can keep the evidence.
*
* It is deliberately *client*-side and derives everything from attributes the gateway
* already sends. The ring buffer is restored from an archive across deployments, so a
* record written by yesterday's build is in the window beside one written a minute ago;
* a shaping rule that lived on the server would render the older half as raw text for as
* long as the archive holds it. Nothing here asks the API for a field it does not have.
*
* Everything below is pure. `shape` is memoised on the event object itself, which is what
* lets the table filter and re-window twenty thousand records without re-deriving them. */
export type LogTone = 'ok' | 'warn' | 'bad' | 'info' | 'note' | 'data' | 'idle' | 'quiet';
export interface Shaped {
/** Stable identity for a service badge — also the value the service filter matches on. */
serviceKey: string;
service: string;
component: string;
/** The verb: an HTTP method, or a short word for an application event. */
action: string;
/** What happened, in words: "GET Notifications", "Started Blue Bloods · S04E08". */
summary: string;
/** Who or what it was for. Medium weight, beside the summary. */
context: string;
/** The one line that explains a failure, printed under the row rather than hidden. */
detail: string;
result: { label: string; short: string; tone: LogTone } | null;
durationMs: number | null;
method: string;
status: number | null;
/** The raw message, which is what the Event filter matches on. */
eventKey: string;
level: string;
time: string;
day: string;
dayKey: string;
/** Whether the row earns a second line. */
tall: boolean;
haystack: string;
fields: [string, unknown][];
attributes: Record<string, unknown>;
}
/* ---------- services ---------- */
/* A subsystem's identity has to be the same every time it appears or there is nothing to
* recognise. The label is the identity; the tone is a secondary cue and is drawn from the
* console's existing secondary palette, never from the verdict colours green, amber and
* red mean good, look and wrong on every other page and must not start meaning "playback"
* here. Related subsystems share a tone on purpose: five hues that mean something are
* worth more than a dozen that only mean "different". */
const SERVICE_TONE: Record<string, LogTone> = {
gateway: 'quiet',
auth: 'note',
playback: 'info',
media: 'info',
emby: 'data',
library: 'data',
subtitles: 'data',
search: 'note',
tracearr: 'idle',
credits: 'idle',
integrations: 'note',
requests: 'info',
home: 'quiet',
};
export const serviceTone = (key: string): LogTone => SERVICE_TONE[key] ?? 'quiet';
/* The component attribute is derived from the route by the gateway, so it is one flat
* token `admin`, `playback`, `details`. This is where it becomes a place in the server:
* a service worth recognising and the part of it that spoke. */
const PLACES: Record<string, [string, string, string]> = {
admin: ['gateway', 'Gateway', 'Admin'],
installer: ['gateway', 'Gateway', 'Installer'],
api: ['gateway', 'Gateway', 'API'],
health: ['gateway', 'Gateway', 'Health'],
status: ['gateway', 'Gateway', 'Status'],
maintenance: ['gateway', 'Gateway', 'Maintenance'],
'quiet-time': ['gateway', 'Gateway', 'Quiet time'],
webhooks: ['gateway', 'Gateway', 'Webhooks'],
scheduler: ['gateway', 'Gateway', 'Scheduler'],
settings: ['gateway', 'Gateway', 'Settings'],
updates: ['gateway', 'Gateway', 'Updates'],
analytics: ['gateway', 'Gateway', 'Analytics'],
auth: ['auth', 'Auth', 'Session'],
devices: ['auth', 'Auth', 'Devices'],
playback: ['playback', 'Playback', 'Session'],
screensaver: ['media', 'Media', 'Screensaver'],
artwork: ['media', 'Media', 'Artwork'],
details: ['media', 'Media', 'Details'],
search: ['search', 'Search', 'Query'],
home: ['home', 'Home', 'Rows'],
'my-shows': ['home', 'Home', 'My shows'],
recommendations: ['tracearr', 'Tracearr', 'Recommendations'],
'for-you': ['tracearr', 'Tracearr', 'For you'],
library: ['library', 'Library', 'Sync'],
credits: ['credits', 'Credits', 'Scanner'],
ratings: ['media', 'Media', 'Ratings'],
integrations: ['integrations', 'Integrations', 'Arr'],
requests: ['requests', 'Requests', 'Media'],
'emby-health': ['emby', 'Emby', 'Health'],
};
/* A message can be more specific than the route it arrived on. A subtitle search is a
* subtitle event whichever route asked for it, and an upstream failure belongs to Emby
* rather than to the screen that was unlucky enough to be waiting on it. These run after
* the route lookup and only ever narrow it. */
const REFINEMENTS: [RegExp, [string, string, string]][] = [
[/^emby |emby (reachable|unreachable|health)/, ['emby', 'Emby', 'API']],
[/subtitle/, ['subtitles', 'Subtitles', 'Provider']],
[/^sonarr|sonarr /, ['integrations', 'Integrations', 'Sonarr']],
[/^radarr|radarr /, ['integrations', 'Integrations', 'Radarr']],
[/^tracearr/, ['tracearr', 'Tracearr', 'Signals']],
[/^credits/, ['credits', 'Credits', 'Scanner']],
[/^library sync/, ['library', 'Library', 'Sync']],
[/^(signed in|signed out|sign-in rejected)/, ['auth', 'Auth', 'Session']],
[/^device /, ['auth', 'Auth', 'Devices']],
[/^(playback (requested|started|stopped|progress))/, ['playback', 'Playback', 'Session']],
[/^(next episode resolved|trailer playback|trickplay)/, ['playback', 'Playback', 'Player']],
[/^scheduled task/, ['gateway', 'Gateway', 'Scheduler']],
[/^(update offered|update policy)/, ['gateway', 'Gateway', 'Updates']],
];
function placeFor(component: string, message: string): [string, string, string] {
const lower = message.toLowerCase();
for (const [pattern, place] of REFINEMENTS) {
if (pattern.test(lower)) return place;
}
const known = PLACES[component];
if (known) return known;
if (!component) return ['gateway', 'Gateway', 'Server'];
return ['gateway', 'Gateway', titleCase(component.replace(/[-_]/g, ' '))];
}
/* ---------- durations ---------- */
const UNIT_MS: Record<string, number> = {
h: 3_600_000,
m: 60_000,
s: 1000,
ms: 1,
us: 0.001,
'µs': 0.001,
ns: 0.000001,
};
/** Go prints a duration as `2ms`, `1.482s`, `1m30s`, `418µs`. Sum whatever it wrote. */
export function parseDuration(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value) return null;
const matches = value.matchAll(/([0-9]*\.?[0-9]+)(ns|µs|us|ms|h|m|s)/g);
let total = 0;
let seen = false;
for (const match of matches) {
const unit = match[2] ? UNIT_MS[match[2]] : undefined;
if (unit === undefined) continue;
total += Number(match[1]) * unit;
seen = true;
}
return seen ? total : null;
}
export function formatDuration(ms: number): string {
if (ms < 1) return '<1 ms';
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 10_000) return `${(ms / 1000).toFixed(1)} s`;
if (ms < 60_000) return `${Math.round(ms / 1000)} s`;
const minutes = Math.floor(ms / 60_000);
return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`;
}
/* Two thresholds and no more. The point of tinting a duration is that a slow row can be
* found by eye down a column of hundreds; a gradient over every row would just be a
* second colour scheme nobody can read a value out of. */
export const durationTone = (ms: number): LogTone | null =>
ms >= 3000 ? 'bad' : ms >= 1000 ? 'warn' : null;
/* ---------- HTTP ---------- */
const STATUS_TEXT: Record<number, string> = {
200: 'OK',
201: 'Created',
202: 'Accepted',
204: 'No Content',
206: 'Partial Content',
301: 'Moved Permanently',
302: 'Found',
304: 'Not Modified',
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
409: 'Conflict',
412: 'Precondition Failed',
418: 'Client Closed Request',
426: 'Upgrade Required',
429: 'Too Many Requests',
499: 'Client Closed Request',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
};
export function statusText(code: number): string {
const known = STATUS_TEXT[code];
if (known) return known;
if (code >= 500) return 'Server Error';
if (code >= 400) return 'Client Error';
if (code >= 300) return 'Redirected';
if (code >= 200) return 'OK';
return 'Response';
}
const statusTone = (code: number): LogTone =>
code >= 500 ? 'bad' : code >= 400 ? 'warn' : code >= 300 ? 'quiet' : 'ok';
/* A few routes are named things rather than paths, and every one of them is high traffic:
* the health probe, the status poll and artwork are most of what a busy log contains, so
* they are the ones worth reading as words. */
const NAMED_PATHS: Record<string, string> = {
'/healthz': 'Health probe',
'/readyz': 'Readiness probe',
'/v1/status': 'Status poll',
'/v1/home': 'Home rows',
'/v1/features': 'Features',
'/v1/preferences': 'Preferences',
'/v1/theme': 'Theme',
'/v1/magic': 'Magic pick',
'/v1/calendar': 'TV calendar',
'/v1/search': 'Search',
'/v1/update': 'Update check',
};
/* An id is a segment with a number in it that is not a word every Emby id, every user id
* and every frame number, and none of `playback`, `trickplay` or `notifications`. Route
* segments in this gateway are lower-case words, so "contains a digit" is a sound test and
* a wrong answer costs a word in a summary rather than anything an operator acts on. */
const idLike = (segment: string) => /\d/.test(segment) || segment.length > 24;
/** `/admin/api/notifications` reads as "Notifications"; the path itself stays in the
* drawer, the tooltip and the export. An id is dropped rather than printed nobody can
* read one, and it is the thing that makes two rows of the same route look different. */
export function prettyPath(path: string): string {
const named = NAMED_PATHS[path];
if (named) return named;
const segments = path.split('/').filter((part) => part && part !== 'v1' && part !== 'api');
if (segments[0] === 'admin') segments.shift();
const words = segments.filter((part) => !idLike(part));
if (words.length === 0) return path;
return titleCase(words.join(' ').replace(/[-_.]/g, ' ').replace(/\s+/g, ' ').trim());
}
/* ---------- wording ---------- */
const titleCase = (value: string) =>
value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
const sentence = (message: string) => titleCase(message.replace(/_/g, ' '));
const text = (value: unknown): string =>
value === undefined || value === null ? '' : String(value);
/* `client=unknown` and `protocol=unknown` are what the gateway writes when a request did
* not say, which is most of them. They are facts and they belong in the export; they are
* not information and they must not take a column. */
const KNOWN_NOTHING = new Set(['', 'unknown', 'none', 'null', '<nil>', '0']);
const informative = (value: unknown) => !KNOWN_NOTHING.has(text(value).toLowerCase());
/* The one attribute that says what the row is *about*, in the order a person would look
* for it. A title beats an id every time, which is the whole reason the gateway logs one. */
const SUBJECT_KEYS = ['title', 'series', 'name', 'query', 'item_title', 'file'];
/* Non-HTTP outcomes worth a result chip. The key is the attribute; the tone is the
* verdict. Anything not listed simply has no result rather than an invented one. */
const PLAY_METHOD_TONE: Record<string, LogTone> = {
directplay: 'ok',
direct: 'ok',
directstream: 'ok',
transcode: 'warn',
transcoding: 'warn',
};
function resultFor(
attributes: Record<string, unknown>,
level: string,
status: number | null,
): Shaped['result'] {
if (status !== null) {
return { label: `${status} ${statusText(status)}`, short: String(status), tone: statusTone(status) };
}
if (informative(attributes.error)) {
return { label: 'Failed', short: 'Failed', tone: level === 'WARN' ? 'warn' : 'bad' };
}
const method = text(attributes.play_method).toLowerCase().replace(/[\s_-]/g, '');
if (method && !KNOWN_NOTHING.has(method)) {
// Emby writes `DirectPlay`; a person reads "Direct Play".
const label = titleCase(text(attributes.play_method).replace(/([a-z])([A-Z])/g, '$1 $2'));
return { label, short: label, tone: PLAY_METHOD_TONE[method] ?? 'info' };
}
if (informative(attributes.cache)) {
const hit = /hit|true|yes/i.test(text(attributes.cache));
return {
label: hit ? 'Cached' : 'Cache miss',
short: hit ? 'Cached' : 'Miss',
tone: hit ? 'data' : 'quiet',
};
}
if (level === 'ERROR') return { label: 'Failed', short: 'Failed', tone: 'bad' };
if (level === 'WARN') return { label: 'Warning', short: 'Warning', tone: 'warn' };
return null;
}
/* ---------- context line ---------- */
/* Who it was for, and the one or two facts that make an application event mean something.
* Kept short on purpose: this sits beside the summary, and a context line that wraps has
* stopped being context. */
function contextFor(attributes: Record<string, unknown>): string {
const parts: string[] = [];
if (informative(attributes.user)) parts.push(text(attributes.user));
if (informative(attributes.device)) parts.push(text(attributes.device));
const marker = parseDuration(attributes.marker_ms);
if (marker !== null && marker > 0) parts.push(`Start ${clock(marker)}`);
const position = parseDuration(attributes.position);
if (position !== null && position > 0) parts.push(`At ${clock(position)}`);
if (informative(attributes.watched)) parts.push(`${text(attributes.watched)} watched`);
if (informative(attributes.reason)) parts.push(text(attributes.reason));
return parts.slice(0, 3).join(' · ');
}
/** A position inside a programme is read as a time, not as a number of milliseconds. */
function clock(ms: number): string {
const total = Math.round(ms / 1000);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (value: number) => String(value).padStart(2, '0');
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
/* ---------- field grouping for the drawer ---------- */
/* The drawer answers "exactly how and why", and it answers it in sections rather than as
* one alphabetical dump. A key that belongs to no section still appears under Details
* because a record must never be able to hide a field from the person reading it. */
export const DRAWER_SECTIONS: { title: string; keys: string[] }[] = [
{ title: 'Request', keys: ['method', 'path', 'query_keys', 'status', 'cache', 'client', 'protocol', 'host'] },
{
title: 'Context',
keys: [
'user', 'user_id', 'device', 'device_id', 'item', 'title', 'series', 'type',
'play_method', 'play_session_id', 'media_source_id', 'position', 'resume', 'runtime',
'watched', 'subtitles', 'subtitle_track', 'subtitle_language', 'event_name',
],
},
{ title: 'Diagnostics', keys: ['error', 'stack', 'correlation', 'version', 'gateway_version', 'duration'] },
];
const SECTIONED = new Set(DRAWER_SECTIONS.flatMap((section) => section.keys));
export const isSectioned = (key: string) => SECTIONED.has(key);
/* ---------- shaping ---------- */
const timeFormat = new Intl.DateTimeFormat('en-NZ', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const dayFormat = new Intl.DateTimeFormat('en-NZ', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
// Records are immutable for their retained lifetime, so a WeakMap gives the derived view
// exactly that lifetime without adding private fields to anything the export writes out.
const cache = new WeakMap<LogEvent, Shaped>();
export function shape(event: LogEvent): Shaped {
const existing = cache.get(event);
if (existing) return existing;
const value = derive(event);
cache.set(event, value);
return value;
}
function derive(event: LogEvent): Shaped {
const attributes = event.attributes ?? {};
const message = event.message ?? '';
const [serviceKey, service, component] = placeFor(text(attributes.component), message);
const path = text(attributes.path);
// `method` is only an HTTP method when there is a route beside it: the credits scanner
// logs a detection method under the same key, and a filter offering VISUAL beside GET
// would be two different questions sharing a control.
const method = path ? text(attributes.method).toUpperCase() : '';
const statusRaw = Number(attributes.status);
const status = path && Number.isFinite(statusRaw) && statusRaw > 0 ? statusRaw : null;
const isRequest = message === 'request' && Boolean(path);
const subjectKey = SUBJECT_KEYS.find((key) => informative(attributes[key]));
const subject = subjectKey ? text(attributes[subjectKey]) : '';
let action: string;
let summary: string;
if (isRequest) {
action = method || 'HTTP';
summary = prettyPath(path);
} else if (path && method) {
action = method;
summary = subject ? `${sentence(message)} · ${subject}` : `${sentence(message)}${prettyPath(path)}`;
} else {
action = '';
summary = subject ? `${sentence(message)} · ${subject}` : sentence(message);
}
const durationMs = parseDuration(
attributes.duration ?? attributes.duration_ms ?? attributes.negotiation_duration,
);
const detail = informative(attributes.error) ? text(attributes.error) : '';
const context = contextFor(attributes);
const occurred = new Date(event.occurredAt);
const fields = Object.entries(attributes);
const shaped: Shaped = {
serviceKey,
service,
component,
action,
summary,
context,
detail,
result: resultFor(attributes, event.level, status),
durationMs,
method,
status,
eventKey: message,
level: event.level,
time: `${timeFormat.format(occurred)}.${String(occurred.getMilliseconds()).padStart(3, '0')}`,
day: dayFormat.format(occurred),
dayKey: occurred.toDateString(),
// An error explains itself on a second line; so does an application event carrying a
// person or a position. Ordinary request traffic — which is most of a log — stays on
// one, because density is the whole reason this page is worth watching.
tall: Boolean(detail) || (Boolean(context) && !isRequest),
haystack: [
message, service, component, summary, context, detail,
...fields.flat().map(text),
]
.join(' ')
.toLowerCase(),
fields,
attributes,
};
return shaped;
}
/* ---------- filtering ---------- */
export const LEVEL_RANK: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
export interface LogFilters {
level: string;
service: string;
component: string;
event: string;
method: string;
/** '' | '2xx' | '3xx' | '4xx' | '5xx' | 'error' (anything at or above 400). */
status: string;
/** Minimum duration in milliseconds; 0 means no duration filter. */
slower: number;
text: string;
}
export const EMPTY_FILTERS: LogFilters = {
level: 'INFO',
service: '',
component: '',
event: '',
method: '',
status: '',
slower: 0,
text: '',
};
function statusMatches(rule: string, status: number | null): boolean {
if (!rule) return true;
if (status === null) return false;
if (rule === 'error') return status >= 400;
const band = Number(rule[0]);
return Math.floor(status / 100) === band;
}
export function matches(event: LogEvent, filters: LogFilters, search: string): boolean {
if ((LEVEL_RANK[event.level] ?? 0) < (LEVEL_RANK[filters.level] ?? 20)) return false;
const view = shape(event);
if (filters.service && view.serviceKey !== filters.service) return false;
if (filters.component && view.component !== filters.component) return false;
if (filters.event && view.eventKey !== filters.event) return false;
if (filters.method && view.method !== filters.method) return false;
if (!statusMatches(filters.status, view.status)) return false;
if (filters.slower > 0 && (view.durationMs ?? 0) < filters.slower) return false;
if (search && !view.haystack.includes(search)) return false;
return true;
}
/** The facets actually present in what has been retained. Offering a service nothing has
* logged is a filter that can only ever empty the table. */
export function facets(records: LogEvent[]): {
services: { key: string; label: string }[];
components: string[];
events: string[];
methods: string[];
} {
const services = new Map<string, string>();
const components = new Set<string>();
const events = new Set<string>();
const methods = new Set<string>();
for (const record of records) {
const view = shape(record);
services.set(view.serviceKey, view.service);
components.add(view.component);
events.add(view.eventKey);
if (view.method) methods.add(view.method);
}
return {
services: [...services].map(([key, label]) => ({ key, label })).sort((a, b) => a.label.localeCompare(b.label)),
components: [...components].sort((a, b) => a.localeCompare(b)),
events: [...events].sort((a, b) => a.localeCompare(b)),
methods: [...methods].sort((a, b) => a.localeCompare(b)),
};
}
/** The chips shown above the table: one per narrowing in force, each removable. */
export function activeChips(
filters: LogFilters,
services: { key: string; label: string }[],
): { key: keyof LogFilters; label: string }[] {
const chips: { key: keyof LogFilters; label: string }[] = [];
if (filters.service) {
const label = services.find((entry) => entry.key === filters.service)?.label ?? filters.service;
chips.push({ key: 'service', label: `Service: ${label}` });
}
if (filters.component) chips.push({ key: 'component', label: `Component: ${filters.component}` });
if (filters.event) chips.push({ key: 'event', label: `Event: ${filters.event}` });
if (filters.method) chips.push({ key: 'method', label: `Method: ${filters.method}` });
if (filters.status) {
chips.push({
key: 'status',
label: `Status: ${filters.status === 'error' ? '≥400' : filters.status}`,
});
}
if (filters.slower > 0) {
chips.push({ key: 'slower', label: `Duration: >${formatDuration(filters.slower)}` });
}
if (filters.text) chips.push({ key: 'text', label: `Search: ${filters.text}` });
return chips;
}
+74 -1
View File
@@ -3,7 +3,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { initials, num, presence, recent, when } from '../lib/format';
import { initials, num, presence, recent, watchTime, when } from '../lib/format';
import {
Banner,
Button,
@@ -16,6 +16,7 @@ import {
Loading,
PageHead,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import type { DeviceVersion } from '../api/types';
@@ -78,9 +79,24 @@ interface NotificationPreferences {
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. */
interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
monthMs: number;
totalMs: number;
weekSessions: number;
monthSessions: number;
lastWatchedAt?: string;
}
interface AccountDetail {
id: string;
username: string;
@@ -97,6 +113,7 @@ interface AccountDetail {
preferences?: Record<string, unknown>;
};
notifications: NotificationPreferences;
watchTime?: WatchTime;
}
interface AccountsPayload {
@@ -338,6 +355,53 @@ export function AccountPage() {
</Card>
</Grid>
{/* Read from Tracearr and shown only when Tracearr has an answer. The card is absent
rather than empty for a household running none: a permanently blank panel on every
account page teaches an operator to scroll past that part of the screen. */}
{account.watchTime?.matched ? (
<Card
title="Watch time"
intro="From Tracearr, for this person across every client — not only Memby. The week runs from Monday and the month from the first, both in the household's own time."
icon="pulse"
tone="data"
actions={
account.watchTime.tracearrUsername ? (
<Chip>{account.watchTime.tracearrUsername}</Chip>
) : null
}
>
<Tiles
tiles={[
{
label: `this week · ${num(account.watchTime.weekSessions)} session${account.watchTime.weekSessions === 1 ? '' : 's'}`,
value: watchTime(account.watchTime.weekMs),
icon: 'pulse',
tone: 'data',
},
{
label: `this month · ${num(account.watchTime.monthSessions)} session${account.watchTime.monthSessions === 1 ? '' : 's'}`,
value: watchTime(account.watchTime.monthMs),
icon: 'calendar',
tone: 'info',
},
{
label: 'since Tracearr started recording',
value: watchTime(account.watchTime.totalMs),
icon: 'clock',
tone: 'note',
},
{
label: 'last watched',
value: when(account.watchTime.lastWatchedAt),
icon: 'history',
tone: undefined,
small: true,
},
]}
/>
</Card>
) : null}
<Card
title="Notifications"
intro="Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release."
@@ -424,6 +488,15 @@ export function AccountPage() {
setNotifications((current) => current && { ...current, libraryAlerts })
}
/>
<Toggle
label="Weekly watch-time summary"
hint="Send this person their week-to-date and month-to-date viewing on Sunday evening, and a summary of the month just gone once it ends. Needs Tracearr."
checked={notifications.watchTimeDigest}
disabled={!notifications.enabled}
onChange={(watchTimeDigest) =>
setNotifications((current) => current && { ...current, watchTimeDigest })
}
/>
<Toggle
label="Service status"
hint="Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies."
+39 -1
View File
@@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { initials, num, presence, recent, when } from '../lib/format';
import { initials, num, presence, recent, watchTime, when } from '../lib/format';
import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
import type { KnownClient } from '../api/types';
@@ -9,6 +9,21 @@ import type { KnownClient } from '../api/types';
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
/* Watch time comes from Tracearr, and `matched` is the field that matters: a household
running no Tracearr, and a person Tracearr has never seen, both arrive as zeroes. Drawing
those as "0 min this week" would have an operator asking why somebody stopped watching
when the real answer is that nothing was asked. */
interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
monthMs: number;
totalMs: number;
weekSessions: number;
monthSessions: number;
lastWatchedAt?: string;
}
interface Account {
id: string;
username: string;
@@ -16,6 +31,7 @@ interface Account {
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
watchTime?: WatchTime;
}
interface AccountsResponse {
@@ -32,6 +48,10 @@ export function AccountsPage() {
const queued = accounts.filter(
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
).length;
/* Summed from the same rows the list draws, so the tile and the column underneath it can
never disagree a separate total query is how those two come apart. */
const tracked = accounts.filter((account) => account.watchTime?.matched);
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
return (
<>
@@ -60,6 +80,16 @@ export function AccountsPage() {
},
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
...(tracked.length
? [
{
label: 'watched by the household this week',
value: watchTime(weekMs),
icon: 'pulse' as const,
tone: 'data' as const,
},
]
: []),
]}
/>
@@ -78,6 +108,7 @@ export function AccountsPage() {
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
const watched = account.watchTime;
return (
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
<span className="list-main">
@@ -90,10 +121,17 @@ export function AccountsPage() {
<span className="list-meta">
{num(list.length)} device{list.length === 1 ? '' : 's'}
{active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)}
{/* The month sits beside the week because a quiet week only means
something next to the month around it. Both are omitted rather
than zeroed for somebody Tracearr has never seen. */}
{watched?.matched
? ` · watched ${watchTime(watched.weekMs)} this week, ${watchTime(watched.monthMs)} this month`
: ''}
</span>
</span>
</span>
<span className="list-actions">
{watched?.matched ? <Tag tone="data">{watchTime(watched.weekMs)}</Tag> : null}
<Tag tone={state.tone}>{state.label}</Tag>
<span className="crumb">Manage</span>
</span>
+30 -3
View File
@@ -16,8 +16,10 @@ interface JourneyEvent {
feature?: string;
source?: string;
target?: string;
itemId?: string;
itemName?: string;
itemType?: string;
playSessionId?: string;
outcome?: string;
}
@@ -32,17 +34,42 @@ const label = (value: string | undefined | null) => {
};
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
/* Where a viewing journey began.
*
* The journey is cut at the playback request, so the first event's `target` is "player" for
* every one of them which is why journeys used to read as somebody appearing in the player
* from nowhere. The request's `source` is the entry point the television stated
* (continue_watching, magic_movie, ...), and it is the answer to this question; the first
* event's own place is only the fallback for a journey that never reached playback. */
const entryPoint = (events: JourneyEvent[]) => {
const request = events.find((event) => event.category === 'playback' && event.action === 'request');
return request?.source ? label(request.source) : place(events[0]);
};
const detail = (event: JourneyEvent) => event.itemName
? `${label(event.itemType)} · ${event.itemName}`
: event.source && event.target ? `${label(event.source)}${label(event.target)}` : place(event);
const verb = (event: JourneyEvent) => ({
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
request: event.category === 'playback' ? 'Started watching' : 'Requested',
stop: 'Stopped watching', start: 'Started', complete: 'Completed',
request: event.category === 'playback' ? 'Asked to watch' : 'Requested',
stop: 'Left the player',
start: event.category === 'playback'
? (event.outcome === 'failure' ? 'Playback failed' : 'Started watching')
: 'Started',
complete: event.category === 'playback'
? (event.outcome === 'completed' ? 'Finished watching' : 'Stopped watching')
: 'Completed',
}[event.action] ?? label(event.action));
function outcome(events: JourneyEvent[]) {
/* A playback step is the verdict on a viewing journey, so it outranks whatever incidental
* outcome a favourite toggle or a settings change left behind on the way in. */
const playback = [...events].reverse().find((event) => event.category === 'playback' && event.outcome);
if (playback?.outcome === 'failure') return { label: 'playback failed', tone: 'warn' as const };
if (playback?.outcome === 'completed') return { label: 'watched', tone: 'ok' as const };
if (playback?.outcome === 'abandoned') return { label: 'stopped part-way', tone: 'note' as const };
if (playback?.outcome === 'success') return { label: 'watched', tone: 'ok' as const };
const explicit = [...events].reverse().find((event) => event.outcome)?.outcome;
if (explicit === 'success' || explicit === 'completed') return { label: label(explicit), tone: 'ok' as const };
if (explicit === 'failure' || explicit === 'cancelled' || explicit === 'abandoned') return { label: label(explicit), tone: 'note' as const };
@@ -91,7 +118,7 @@ export function JourneyViewerPage() {
return <article className="visit" key={journey.key}>
<header><div><b>{when(entry?.occurredAt)}</b><span>Journey {index + 1} · {events.length} recorded steps</span></div><Tag tone={result.tone}>{result.label}</Tag></header>
<div className="journey-answers">
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{place(entry)}</b></div>
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{entryPoint(events)}</b></div>
<div className="journey-answer" data-kind="selection"><Icon name="play" /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
<div className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
</div>
+2 -1
View File
@@ -26,7 +26,8 @@ import {
const FEATURE_CATALOGUE = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
'latest', 'my_shows', 'details', 'playback', 'magic_movie', 'notifications',
'profiles', 'settings',
];
/** The wire values are ids; this is the render, so it is spelled the same boundary the
+623 -157
View File
@@ -9,127 +9,384 @@ import {
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import { api } from '../api/client';
import { num } from '../lib/format';
import { Banner, Button, Card, Field, Note, PageHead } from '../components/ui';
import { Banner, Button, Card, Note, PageHead } from '../components/ui';
import { Icon } from '../components/Icon';
import {
DRAWER_SECTIONS,
EMPTY_FILTERS,
activeChips,
durationTone,
facets,
formatDuration,
isSectioned,
matches,
serviceTone,
shape,
} from '../lib/logmodel';
import type { LogFilters, Shaped } from '../lib/logmodel';
import type { LogEvent, LogResponse } from '../api/types';
/* The live server log.
*
* Network delivery is already cursor based: each server record crosses the wire once.
* Rendering is virtualised as well, so retaining and filtering thousands of records does
* not mean mounting thousands of details trees. Only the rows around the viewport exist
* in the DOM; selecting one opens its complete structured data below the window. */
* Network delivery is cursor based: each server record crosses the wire once. Rendering is
* virtualised, so retaining and filtering thousands of records does not mean mounting
* thousands of rows only those around the viewport exist in the DOM.
*
* What changed, and why: the table used to print a record's attribute bag as one string,
* which is complete and unreadable. `lib/logmodel` turns a record into the four answers a
* person is actually after when, which part of the server, what happened, did it work
* and this file is only the table, the filters and the drawer over that. The drawer is
* where the evidence lives now, rather than where the meaning was.
*
* Three properties are easy to give back and worth keeping:
*
* - **Rows have two heights, not one.** Repetitive request traffic stays on one line,
* because density is the reason this page is worth watching; a failure or a real
* application event earns a second. That means the virtual window is driven by a prefix
* sum of row heights rather than by multiplication, computed once per filter change.
* - **Pause holds the view, not the connection.** Draining continues while paused and the
* arrivals are held in a buffer, so the cursor keeps up with the server's ring buffer
* and resuming is a flush rather than a stampede the previous behaviour let the ring
* overwrite records the operator had paused specifically in order to read around.
* - **Nothing snaps.** The view follows the tail only while the operator is already at
* it; scrolling up hands them a `Jump to latest` instead. */
const RANKS: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const RETAIN = 20_000;
const POLL_MS = 5_000;
const ROW_HEIGHT = 48;
const ROW_COMPACT = 30;
const ROW_TALL = 48;
const DAY_HEIGHT = 26;
const HEADER_HEIGHT = 31;
const OVERSCAN = 8;
const OVERSCAN = 10;
const FIELD_ORDER = [
'component',
'user',
'device',
'client',
'protocol',
'method',
'path',
'status',
'duration',
'version',
'gateway_version',
const LEVELS = [
{ value: 'TRACE', label: 'Everything' },
{ value: 'DEBUG', label: 'Debug+' },
{ value: 'INFO', label: 'Info+' },
{ value: 'WARN', label: 'Warnings+' },
{ value: 'ERROR', label: 'Errors only' },
];
const FIELD_RANK = new Map(FIELD_ORDER.map((key, index) => [key, index]));
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' });
interface CachedEvent {
fields: [string, unknown][];
summary: string;
haystack: string;
occurred: string;
}
const STATUS_BANDS = [
{ value: '', label: 'Any result' },
{ value: '2xx', label: 'Success (2xx)' },
{ value: '3xx', label: 'Redirect (3xx)' },
{ value: '4xx', label: 'Client error (4xx)' },
{ value: '5xx', label: 'Server error (5xx)' },
{ value: 'error', label: 'Failed (≥400)' },
];
// API event objects remain stable for their retained lifetime. A WeakMap gives formatting
// and search indexing the same lifetime without adding private fields to JSON exports.
const eventCache = new WeakMap<LogEvent, CachedEvent>();
function cached(event: LogEvent): CachedEvent {
const existing = eventCache.get(event);
if (existing) return existing;
const fields = Object.entries(event.attributes ?? {}).sort((left, right) => {
const leftRank = FIELD_RANK.get(left[0]) ?? (left[0] === 'error' ? 1000 : 100);
const rightRank = FIELD_RANK.get(right[0]) ?? (right[0] === 'error' ? 1000 : 100);
return leftRank - rightRank || left[0].localeCompare(right[0]);
});
const value = {
fields,
summary: fields.map(([key, fieldValue]) => `${key}=${String(fieldValue)}`).join(' '),
haystack: [event.message, ...fields.flat()].join(' ').toLowerCase(),
occurred: dateTime.format(new Date(event.occurredAt)),
};
eventCache.set(event, value);
return value;
}
const SLOWER = [
{ value: 0, label: 'Any duration' },
{ value: 100, label: 'Slower than 100 ms' },
{ value: 500, label: 'Slower than 500 ms' },
{ value: 1000, label: 'Slower than 1 s' },
{ value: 3000, label: 'Slower than 3 s' },
];
const readableKey = (key: string) => key.replace(/_/g, ' ');
const LogLine = memo(function LogLine({
/* ---------- one row ---------- */
/** A value in the table that is also a filter. Clicking a service, a level, a method or a
* status is by some way the fastest way into a subsystem, and it costs nothing to make
* the thing already printed be the control. */
function Facet({
onPick,
className,
title,
children,
...rest
}: {
onPick: () => void;
className: string;
title: string;
children: ReactNode;
} & Record<string, unknown>) {
return (
<button type="button" className={`logfacet ${className}`} title={title} onClick={onPick} {...rest}>
{children}
</button>
);
}
const LogRow = memo(function LogRow({
event,
index,
view,
top,
height,
selected,
onInspect,
onFilter,
}: {
event: LogEvent;
index: number;
view: Shaped;
top: number;
height: number;
selected: boolean;
onInspect: (sequence: number) => void;
onFilter: (patch: Partial<LogFilters>) => void;
}) {
const display = cached(event);
const slow = view.durationMs !== null ? durationTone(view.durationMs) : null;
return (
<div
className="logline"
data-level={event.level}
data-virtual="true"
style={{ transform: `translateY(${index * ROW_HEIGHT}px)` }}
className="logrow"
data-level={view.level}
data-selected={selected || undefined}
style={{ transform: `translateY(${top}px)`, height: `${height}px` }}
>
<time title={event.occurredAt}>
{display.occurred}
<small>#{event.sequence}</small>
<time className="logrow-time" title={event.occurredAt}>
{view.time}
</time>
<span className="lvl">{event.level}</span>
<span className="msg" title={event.message}>{event.message}</span>
<Facet
className="logrow-level"
data-level={view.level}
title={`Show ${view.level} and above`}
onPick={() => onFilter({ level: view.level })}
>
{view.level}
</Facet>
<span className="logrow-place">
<Facet
className="logrow-service"
data-tone={serviceTone(view.serviceKey)}
title={`Filter to ${view.service}`}
onPick={() => onFilter({ service: view.serviceKey, component: '' })}
>
{view.service}
</Facet>
<span className="logrow-sep" aria-hidden="true">
</span>
<Facet
className="logrow-component"
title={`Filter to ${view.component}`}
onPick={() => onFilter({ component: view.component })}
>
{view.component}
</Facet>
</span>
<button
type="button"
className="logattrs-button"
title={display.summary || 'No structured details'}
className="logrow-summary"
title={view.detail || view.summary}
onClick={() => onInspect(event.sequence)}
>
{display.summary || 'View record'}
<span className="logrow-line">
{view.action ? (
<b className="logrow-action" data-method={view.method || undefined}>
{view.action}
</b>
) : null}
<span className="logrow-text">{view.summary}</span>
</span>
{view.detail ? (
<span className="logrow-error"> {view.detail}</span>
) : view.context ? (
<span className="logrow-context">{view.context}</span>
) : null}
</button>
<span className="logrow-result">
{view.result ? (
<Facet
className="logrow-verdict"
data-tone={view.result.tone}
title={
view.status !== null ? `Filter to ${view.status}` : `Filter to ${view.eventKey}`
}
onPick={() =>
view.status !== null
? onFilter({ status: `${Math.floor(view.status / 100)}xx` })
: onFilter({ event: view.eventKey })
}
>
{view.result.label}
</Facet>
) : null}
</span>
<span className="logrow-duration" data-tone={slow ?? undefined}>
{view.durationMs !== null ? formatDuration(view.durationMs) : ''}
</span>
</div>
);
});
/* ---------- the drawer ---------- */
function Drawer({
event,
view,
onClose,
}: {
event: LogEvent;
view: Shaped;
onClose: () => void;
}) {
const [copied, setCopied] = useState(false);
const leftovers = view.fields.filter(([key]) => !isSectioned(key) && key !== 'component');
const copy = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(event, null, 2));
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
setCopied(false);
}
};
const sections = DRAWER_SECTIONS.map((section) => ({
title: section.title,
rows: section.keys
.map((key) => [key, view.attributes[key]] as [string, unknown])
.filter(([, value]) => value !== undefined && value !== null && String(value) !== ''),
})).filter((section) => section.rows.length > 0);
return (
<section className="logdrawer" aria-label={`Log record ${event.sequence}`}>
<header className="logdrawer-head">
<div>
<p className="logdrawer-place">
<span className="logrow-service" data-tone={serviceTone(view.serviceKey)}>
{view.service}
</span>
<span className="logrow-sep" aria-hidden="true">
</span>
{view.component}
</p>
<b>{view.summary}</b>
{view.detail ? <p className="logdrawer-error">{view.detail}</p> : null}
</div>
<div className="logdrawer-actions">
<Button size="sm" variant="quiet" onClick={copy} icon="download">
{copied ? 'Copied' : 'Copy JSON'}
</Button>
<Button size="sm" variant="quiet" onClick={onClose} icon="close">
Close
</Button>
</div>
</header>
<div className="logdrawer-grid">
<div className="logdrawer-section">
<h4>Overview</h4>
<dl>
<dt>Time</dt>
<dd>
{view.day} {view.time}
</dd>
<dt>Level</dt>
<dd>{view.level}</dd>
<dt>Service</dt>
<dd>
{view.service} {view.component}
</dd>
<dt>Event</dt>
<dd>{view.eventKey}</dd>
{view.result ? (
<>
<dt>Result</dt>
<dd>{view.result.label}</dd>
</>
) : null}
{view.durationMs !== null ? (
<>
<dt>Duration</dt>
<dd>{formatDuration(view.durationMs)}</dd>
</>
) : null}
<dt>Record</dt>
<dd>#{event.sequence}</dd>
</dl>
</div>
{sections.map((section) => (
<div className="logdrawer-section" key={section.title}>
<h4>{section.title}</h4>
<dl>
{section.rows.map(([key, value]) => (
<Fragment key={key}>
<dt>{readableKey(key)}</dt>
<dd>{String(value)}</dd>
</Fragment>
))}
</dl>
</div>
))}
{leftovers.length ? (
<div className="logdrawer-section">
<h4>Details</h4>
<dl>
{leftovers.map(([key, value]) => (
<Fragment key={key}>
<dt>{readableKey(key)}</dt>
<dd>{String(value)}</dd>
</Fragment>
))}
</dl>
</div>
) : null}
</div>
<details className="logdrawer-raw">
<summary>Raw event</summary>
<pre>{JSON.stringify(event, null, 2)}</pre>
</details>
</section>
);
}
/* ---------- the page ---------- */
export function LogsPage() {
const [records, setRecords] = useState<LogEvent[]>([]);
const [dropped, setDropped] = useState(0);
const [paused, setPaused] = useState(false);
const [level, setLevel] = useState('INFO');
const [search, setSearch] = useState('');
const [held, setHeld] = useState(0);
const [filters, setFilters] = useState<LogFilters>(EMPTY_FILTERS);
const [error, setError] = useState('');
const [viewport, setViewport] = useState({ top: 0, height: 600 });
const [atTail, setAtTail] = useState(true);
const [selectedSequence, setSelectedSequence] = useState<number | null>(null);
const deferredSearch = useDeferredValue(search.trim().toLowerCase());
const deferredSearch = useDeferredValue(filters.text.trim().toLowerCase());
const cursor = useRef(0);
const fetching = useRef(false);
const viewGeneration = useRef(0);
const view = useRef<HTMLDivElement>(null);
const pinned = useRef(true);
const scrollFrame = useRef<number | undefined>(undefined);
// Arrivals while paused. Held here rather than left on the server: the ring buffer is
// finite, and a pause taken in order to read something is exactly when it must not be
// overwritten underneath the operator.
const holding = useRef<LogEvent[]>([]);
// Read by `drain`, which is a timer callback rather than a render. Written in an effect
// rather than during render: a render that concurrent React discards must not be able to
// decide whether the next batch of arrivals is shown or held.
const pausedRef = useRef(paused);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
const admit = useCallback((batch: LogEvent[]) => {
setRecords((current) => {
const next = current.concat(batch);
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
}, []);
const drain = useCallback(async () => {
if (paused || fetching.current || document.hidden) return;
if (fetching.current || document.hidden) return;
fetching.current = true;
const generation = viewGeneration.current;
const batch: LogEvent[] = [];
@@ -148,23 +405,27 @@ export function LogsPage() {
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
// One React update for a complete catch-up prevents the initial 5,000-record drain
// from redrawing the page once per network page.
// One React update for a complete catch-up prevents the initial drain from redrawing
// the page once per network page.
if (batch.length > 0 && generation === viewGeneration.current) {
setRecords((current) => {
const next = current.concat(batch);
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
if (pausedRef.current) {
holding.current = holding.current.concat(batch);
if (holding.current.length > RETAIN) {
holding.current = holding.current.slice(holding.current.length - RETAIN);
}
setHeld(holding.current.length);
} else {
admit(batch);
}
}
if (droppedInDrain > 0 && generation === viewGeneration.current) {
setDropped((current) => current + droppedInDrain);
}
fetching.current = false;
}
}, [paused]);
}, [admit]);
useEffect(() => {
if (paused) return;
let timer: number | undefined;
const schedule = () => {
window.clearInterval(timer);
@@ -181,46 +442,121 @@ export function LogsPage() {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', visibilityChanged);
};
}, [drain, paused]);
}, [drain]);
const filtered = useMemo(() => {
const minimum = RANKS[level] ?? 20;
return records.filter(
(event) =>
(RANKS[event.level] ?? 0) >= minimum &&
(!deferredSearch || cached(event).haystack.includes(deferredSearch)),
);
}, [records, level, deferredSearch]);
// Both halves set the ref straight away as well as the state. A drain landing between
// the click and the commit would otherwise file its batch on the wrong side of the
// pause — held records with the button already reading "Pause", which nothing would
// ever flush.
const pause = useCallback(() => {
pausedRef.current = true;
setPaused(true);
}, []);
const resume = useCallback(() => {
pausedRef.current = false;
const waiting = holding.current;
holding.current = [];
setHeld(0);
setPaused(false);
if (waiting.length) admit(waiting);
}, [admit]);
const patch = useCallback((next: Partial<LogFilters>) => {
setFilters((current) => ({ ...current, ...next }));
}, []);
const filtered = useMemo(
() => records.filter((event) => matches(event, filters, deferredSearch)),
[records, filters, deferredSearch],
);
/* Row geometry. Two heights and a day divider mean the window cannot be found by
* dividing a scroll offset, so heights are accumulated once per filter change and the
* first visible row is found by binary search. Twenty thousand records is one pass over
* a typed array cheaper than the render it replaces. */
const layout = useMemo(() => {
const tops = new Float64Array(filtered.length + 1);
const heights = new Uint8Array(filtered.length);
const divider = new Uint8Array(filtered.length);
let y = 0;
let day = '';
for (let index = 0; index < filtered.length; index += 1) {
const record = filtered[index];
if (!record) continue;
const shaped = shape(record);
if (shaped.dayKey !== day) {
divider[index] = 1;
day = shaped.dayKey;
y += DAY_HEIGHT;
}
const height = shaped.tall ? ROW_TALL : ROW_COMPACT;
tops[index] = y;
heights[index] = height;
y += height;
}
tops[filtered.length] = y;
return { tops, heights, divider, total: y };
}, [filtered]);
const bodyTop = Math.max(0, viewport.top - HEADER_HEIGHT);
const first = useMemo(() => {
let low = 0;
let high = filtered.length;
while (low < high) {
const middle = (low + high) >> 1;
if ((layout.tops[middle] ?? 0) + (layout.heights[middle] ?? 0) <= bodyTop) low = middle + 1;
else high = middle;
}
return Math.max(0, low - OVERSCAN);
}, [layout, bodyTop, filtered.length]);
const last = useMemo(() => {
const limit = bodyTop + viewport.height;
let index = first;
while (index < filtered.length && (layout.tops[index] ?? 0) < limit) index += 1;
return Math.min(filtered.length, index + OVERSCAN);
}, [layout, bodyTop, viewport.height, first, filtered.length]);
const windowed = useMemo(() => {
const rows: { event: LogEvent; view: Shaped; index: number }[] = [];
for (let index = first; index < last; index += 1) {
const record = filtered[index];
if (record) rows.push({ event: record, view: shape(record), index });
}
return rows;
}, [filtered, first, last]);
const lastSequence = filtered.at(-1)?.sequence ?? 0;
const bodyTop = Math.max(0, viewport.top - HEADER_HEIGHT);
const count = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2;
// A restrictive filter can make the old scroll offset larger than the new body before
// the browser dispatches its compensating scroll event. Clamp immediately so that
// transition never paints an apparently empty log.
const first = Math.min(
Math.max(0, Math.floor(bodyTop / ROW_HEIGHT) - OVERSCAN),
Math.max(0, filtered.length - count),
);
const windowed = filtered.slice(first, first + count);
const selected = useMemo(
() => records.find((event) => event.sequence === selectedSequence),
[records, selectedSequence],
);
const scrollToTail = useCallback(() => {
const node = view.current;
if (!node) return;
pinned.current = true;
node.scrollTop = node.scrollHeight;
setAtTail(true);
setViewport({ top: node.scrollTop, height: node.clientHeight });
}, []);
useLayoutEffect(() => {
const node = view.current;
if (!node || !pinned.current) return;
node.scrollTop = node.scrollHeight;
setViewport({ top: node.scrollTop, height: node.clientHeight });
}, [lastSequence, deferredSearch, level]);
}, [lastSequence, layout.total]);
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
const onScroll = () => {
const node = view.current;
if (!node) return;
pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < ROW_HEIGHT;
const tail = node.scrollHeight - node.scrollTop - node.clientHeight < ROW_TALL;
pinned.current = tail;
setAtTail(tail);
window.cancelAnimationFrame(scrollFrame.current ?? 0);
scrollFrame.current = window.requestAnimationFrame(() => {
setViewport({ top: node.scrollTop, height: node.clientHeight });
@@ -228,7 +564,7 @@ export function LogsPage() {
};
const exportJson = () => {
const blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' });
const blob = new Blob([JSON.stringify(filtered, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `memby-events-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
@@ -236,39 +572,129 @@ export function LogsPage() {
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
const available = useMemo(() => facets(records), [records]);
const chips = activeChips(filters, available.services);
return (
<>
<PageHead title="Server logs" intro="Structured gateway events as they happen." />
<Banner message={error} />
<Card>
<div className="filters">
<Field label="Level">
<select value={level} onChange={(event) => setLevel(event.target.value)}>
<option value="DEBUG">Debug and above</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
<div className="logbar">
<div className="logbar-filters">
<select
aria-label="Service"
value={filters.service}
onChange={(event) => patch({ service: event.target.value, component: '' })}
>
<option value="">All services</option>
{available.services.map((service) => (
<option key={service.key} value={service.key}>
{service.label}
</option>
))}
</select>
</Field>
<Field label="Filter" grow>
<input
type="search"
value={search}
placeholder="Person, television, title, component, path…"
onChange={(event) => setSearch(event.target.value)}
/>
</Field>
<div className="filter-actions">
<Button onClick={() => setPaused((current) => !current)} icon={paused ? 'play' : 'clock'}>
{paused ? 'Resume' : 'Pause'}
<select
aria-label="Component"
value={filters.component}
onChange={(event) => patch({ component: event.target.value })}
>
<option value="">All components</option>
{available.components.map((component) => (
<option key={component} value={component}>
{component}
</option>
))}
</select>
<select
aria-label="Level"
value={filters.level}
onChange={(event) => patch({ level: event.target.value })}
>
{LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
<select
aria-label="Event"
value={filters.event}
onChange={(event) => patch({ event: event.target.value })}
>
<option value="">All events</option>
{available.events.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
aria-label="Result"
value={filters.status}
onChange={(event) => patch({ status: event.target.value })}
>
{STATUS_BANDS.map((band) => (
<option key={band.value} value={band.value}>
{band.label}
</option>
))}
</select>
<select
aria-label="Method"
value={filters.method}
onChange={(event) => patch({ method: event.target.value })}
>
<option value="">Any method</option>
{available.methods.map((method) => (
<option key={method} value={method}>
{method}
</option>
))}
</select>
<select
aria-label="Duration"
value={String(filters.slower)}
onChange={(event) => patch({ slower: Number(event.target.value) })}
>
{SLOWER.map((option) => (
<option key={option.value} value={String(option.value)}>
{option.label}
</option>
))}
</select>
<label className="logsearch">
<Icon name="search" />
<input
type="search"
value={filters.text}
aria-label="Search logs"
placeholder="Search person, title, service, component, path, request ID…"
onChange={(event) => patch({ text: event.target.value })}
/>
</label>
</div>
<div className="logbar-actions">
<Button
size="sm"
variant="quiet"
onClick={() => (paused ? resume() : pause())}
icon={paused ? 'play' : 'clock'}
>
{paused ? (held ? `Resume (${num(held)})` : 'Resume') : 'Pause'}
</Button>
<Button
size="sm"
variant="quiet"
onClick={() => {
// A page already in flight may finish after this click. Advancing the
// generation makes that batch part of the cleared past, not a flash of
// old lines reappearing after the view was emptied.
// generation makes that batch part of the cleared past, not a flash of old
// lines reappearing after the view was emptied.
viewGeneration.current += 1;
holding.current = [];
setHeld(0);
setRecords([]);
setDropped(0);
setSelectedSequence(null);
@@ -276,57 +702,97 @@ export function LogsPage() {
>
Clear view
</Button>
<Button onClick={exportJson} icon="download">Export JSON</Button>
<Button
size="sm"
variant="quiet"
onClick={exportJson}
icon="download"
title="Export the rows matching the current filters, as delivered by the gateway"
>
Export JSON
</Button>
</div>
</div>
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-label="Server events">
<div className="loghead" aria-hidden="true">
<span>Time</span>
<span>Level</span>
<span>Event</span>
<span>Details</span>
{chips.length ? (
<div className="logchips">
{chips.map((chip) => (
<button
key={chip.key}
type="button"
className="logchip"
onClick={() => patch({ [chip.key]: EMPTY_FILTERS[chip.key] } as Partial<LogFilters>)}
>
{chip.label}
<Icon name="close" />
</button>
))}
<button type="button" className="logchip logchip-clear" onClick={() => setFilters(EMPTY_FILTERS)}>
Clear all
</button>
</div>
{filtered.length === 0 ? (
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
) : (
<div className="logbody" style={{ height: `${filtered.length * ROW_HEIGHT}px` }}>
{windowed.map((event, offset) => (
<LogLine key={event.sequence} event={event} index={first + offset} onInspect={setSelectedSequence} />
))}
) : null}
<div className="logshell">
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-label="Server events">
<div className="loghead" aria-hidden="true">
<span>Time</span>
<span>Level</span>
<span>Service</span>
<span>Event</span>
<span>Result</span>
<span>Duration</span>
</div>
)}
{filtered.length === 0 ? (
<p className="empty">
{records.length === 0 ? 'Waiting for server events…' : 'No events match these filters.'}
</p>
) : (
<div className="logbody" style={{ height: `${layout.total}px` }}>
{windowed.map(({ event, view: shaped, index }) => (
<Fragment key={event.sequence}>
{layout.divider[index] ? (
<div
className="logday"
style={{ transform: `translateY(${(layout.tops[index] ?? 0) - DAY_HEIGHT}px)` }}
>
<span>{shaped.day}</span>
</div>
) : null}
<LogRow
event={event}
view={shaped}
top={layout.tops[index] ?? 0}
height={layout.heights[index] ?? ROW_COMPACT}
selected={event.sequence === selectedSequence}
onInspect={setSelectedSequence}
onFilter={patch}
/>
</Fragment>
))}
</div>
)}
</div>
{!atTail && filtered.length > 0 ? (
<button type="button" className="logtail" onClick={scrollToTail}>
<Icon name="caret" />
Jump to latest
</button>
) : null}
</div>
<p className="hint">
{num(records.length)} retained · {num(filtered.length)} matching
{filtered.length ? ` · ${num(windowed.length)} rows mounted` : ''}
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
{paused ? ' · paused' : ''}
{paused ? ` · paused${held ? `, ${num(held)} held` : ''}` : ''}
</p>
{selected ? (
<section className="log-inspector" aria-label={`Log record ${selected.sequence}`}>
<div className="log-inspector-head">
<div>
<b>{selected.message}</b>
<span>{cached(selected).occurred} · {selected.level} · record #{selected.sequence}</span>
</div>
<Button size="sm" variant="quiet" onClick={() => setSelectedSequence(null)}>Close</Button>
</div>
{cached(selected).fields.length ? (
<dl>
{cached(selected).fields.map(([key, fieldValue]) => (
<Fragment key={key}>
<dt>{readableKey(key)}</dt>
<dd>{String(fieldValue)}</dd>
</Fragment>
))}
</dl>
) : (
<Note>No structured details were attached to this record.</Note>
)}
</section>
<Drawer event={selected} view={shape(selected)} onClose={() => setSelectedSequence(null)} />
) : records.length ? (
<Note>Select a row to see the full record request, context, diagnostics and raw event.</Note>
) : null}
</Card>
</>
+513 -105
View File
@@ -22,7 +22,12 @@
which is what lets a page distinguish one kind of thing from another without every
coloured element reading as a warning. Green means good, amber means look, red means
wrong; the other three mean nothing at all, which is the point. A tone is passed, never
derived: the library is teal, a person violet, a television blue, on every page. */
derived: the library is teal, a person violet, a television blue, on every page.
Beside the accent is one quieter green, `idle`, which is not a seventh meaning but the
accent's meaning held at lower confidence: good, but not this minute. It exists so that
"fine and not connected right now" can stop borrowing amber, which is reserved for
something an operator should look at. Nothing derives it either. */
:root {
color-scheme: dark;
@@ -41,6 +46,13 @@
--accent: #2ea043;
--accent-ink: #56d364;
--accent-wash: rgba(46, 160, 67, 0.16);
/* The quiet green: fine, but not this minute. Same hue as the accent and deliberately
duller, because it is the accent's own meaning at lower confidence rather than a
verdict of its own a television in ordinary use that happens to be switched off is
not something an operator has to do anything about, and amber said it was. */
--idle: #4a8f60;
--idle-ink: #86c79a;
--idle-wash: rgba(74, 143, 96, 0.13);
--danger: #e5534b;
--danger-ink: #ff9b94;
--danger-wash: rgba(229, 83, 75, 0.13);
@@ -1030,6 +1042,10 @@ a.tile:hover {
background: var(--accent-wash);
color: var(--accent-ink);
}
.glyph[data-tone="idle"] {
background: var(--idle-wash);
color: var(--idle-ink);
}
.glyph[data-tone="warn"] {
background: var(--warn-wash);
color: var(--warn-ink);
@@ -1078,6 +1094,11 @@ a.tile:hover {
background: var(--accent-wash);
color: var(--accent-ink);
}
.tag[data-tone="idle"] {
border-color: rgba(74, 143, 96, 0.35);
background: var(--idle-wash);
color: var(--idle-ink);
}
.tag[data-tone="warn"] {
border-color: rgba(239, 196, 107, 0.35);
background: var(--warn-wash);
@@ -1119,6 +1140,9 @@ a.tile:hover {
.chip[data-tone="ok"] {
color: var(--accent-ink);
}
.chip[data-tone="idle"] {
color: var(--idle-ink);
}
.chip[data-tone="warn"] {
color: var(--warn-ink);
}
@@ -1744,6 +1768,109 @@ select {
/* ---------- log / code ---------- */
/* The log table.
Six columns, in the order the questions are asked: when, how bad, which part of the
server, what happened, did it work, how long did it take. The columns are the whole
design an operator learns where to look once and then reads down a column rather than
across a line, which is what makes thirty seconds of watching this page worth anything.
Monospace is spent deliberately rather than applied to the row. The time, the method,
the status and the duration are values to be compared down a column and are set in it;
the summary is a sentence and is set in the sans face, because a sentence in monospace
is slower to read and looks like a dump of something. */
.logbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 10px;
}
.logbar-filters {
display: flex;
flex: 1 1 520px;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.logbar-filters select {
width: auto;
min-width: 0;
height: 30px;
padding: 0 26px 0 9px;
font-size: 12px;
}
/* The actions are secondary to the filters and are marked so by weight, not by distance:
the previous bar gave Pause and Export the same prominence as the controls that decide
what the table contains. */
.logbar-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
margin-left: auto;
}
.logsearch {
position: relative;
display: flex;
flex: 1 1 260px;
align-items: center;
min-width: 200px;
}
.logsearch svg {
position: absolute;
left: 9px;
width: 14px;
height: 14px;
color: var(--quiet);
pointer-events: none;
}
.logsearch input {
width: 100%;
height: 30px;
padding-left: 28px;
font-size: 12px;
}
/* One chip per narrowing in force. They are the only place a filter can be removed one at
a time, which is what makes drilling in by clicking a value in the table reversible. */
.logchips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.logchip {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-lift);
color: var(--muted);
font: 500 11px/1 var(--sans);
}
.logchip svg {
width: 11px;
height: 11px;
opacity: .7;
}
.logchip:hover:not(:disabled) {
border-color: var(--danger);
background: var(--danger-wash);
color: var(--danger-ink);
}
.logchip-clear {
border-style: dashed;
color: var(--quiet);
}
.logshell {
position: relative;
}
.logview {
height: 62vh;
min-height: 320px;
@@ -1751,17 +1878,24 @@ select {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #080a0e;
font: 12px/1.6 var(--mono);
font: 12px/1.5 var(--sans);
contain: layout paint style;
}
.loghead,
.logrow {
display: grid;
grid-template-columns:
92px 52px minmax(150px, 190px) minmax(240px, 1fr)
minmax(96px, 150px) 68px;
gap: 12px;
padding: 0 12px;
}
.loghead {
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
gap: 12px;
padding: 7px 12px;
z-index: 2;
align-items: center;
height: 31px;
border-bottom: 1px solid var(--line);
background: #10131a;
color: var(--quiet);
@@ -1769,142 +1903,413 @@ select {
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
min-width: 760px;
min-width: 900px;
}
.loghead > :last-child {
text-align: right;
}
.logbody {
position: relative;
min-width: 760px;
min-width: 900px;
}
.logline {
display: flex;
gap: 12px;
padding: 3px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.logline[data-virtual="true"] {
/* The date is a divider between days rather than a column repeated on every row. A log
watched live is almost always one day; printing the date 20,000 times to cover the
handful of rows where it changes is the trade the compact timestamp exists to avoid. */
.logday {
position: absolute;
top: 0;
right: 0;
left: 0;
display: grid;
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
display: flex;
align-items: center;
height: 48px;
padding-top: 4px;
padding-bottom: 4px;
white-space: nowrap;
height: 26px;
padding: 0 12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, .03), transparent);
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .08em;
text-transform: uppercase;
}
.logday span {
padding-right: 10px;
background: #080a0e;
}
.logday::after {
content: '';
flex: 1;
height: 1px;
background: var(--line-soft);
}
.logrow {
position: absolute;
top: 0;
right: 0;
left: 0;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, .03);
contain: strict;
}
.logline:hover {
background: rgba(255, 255, 255, 0.03);
.logrow:hover {
background: rgba(255, 255, 255, .035);
}
.logline time {
flex: 0 0 auto;
.logrow[data-selected] {
background: var(--accent-wash);
}
/* A failure is marked on the row and not only in a column, because the thing an operator
is scanning for is a row, and a hairline at the start of one is found faster than a word
two thirds of the way along it. INFO is deliberately unmarked: most of the table is INFO
and a mark every row carries is a mark that says nothing. */
.logrow[data-level="ERROR"] {
box-shadow: inset 2px 0 0 var(--danger);
background: rgba(229, 83, 75, .05);
}
.logrow[data-level="WARN"] {
box-shadow: inset 2px 0 0 var(--warn);
}
.logrow-time {
color: var(--quiet);
font: 11px/1 var(--mono);
font-variant-numeric: tabular-nums;
}
.logline time small {
display: block;
color: var(--muted);
font-size: 10px;
}
.logline .lvl {
flex: 0 0 46px;
font-weight: 700;
}
.logline[data-level="ERROR"] .lvl {
color: var(--danger-ink);
}
.logline[data-level="WARN"] .lvl {
color: var(--warn-ink);
}
.logline[data-level="INFO"] .lvl {
color: var(--info-ink);
}
.logline[data-level="DEBUG"] .lvl {
color: var(--quiet);
}
.logline .msg {
flex: 1 1 auto;
/* Every structured value in the table is also the control that filters to it. They are
buttons rather than links because nothing navigates: the table narrows in place. */
.logfacet {
height: auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.logline .attrs {
color: var(--quiet);
}
@media (min-width: 900px) {
.logline {
display: grid;
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
}
}
.logline .attrs b {
color: var(--muted);
font-weight: 400;
}
.logattrs-button {
display: block;
width: 100%;
min-width: 0;
height: 28px;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: var(--quiet);
background: none;
color: inherit;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.logattrs-button:hover:not(:disabled) {
.logfacet:hover:not(:disabled) {
border: 0;
background: transparent;
color: var(--text);
background: none;
text-decoration: underline;
text-underline-offset: 3px;
}
.logattrs-button:focus-visible {
.logfacet:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.log-inspector {
.logrow-level {
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .06em;
}
.logrow-level[data-level="ERROR"] { color: var(--danger-ink); }
.logrow-level[data-level="WARN"] { color: var(--warn-ink); }
.logrow-level[data-level="INFO"] { color: var(--muted); }
.logrow-level[data-level="DEBUG"],
.logrow-level[data-level="TRACE"] { color: var(--quiet); }
/* Service and component. The service is the recognisable half one word, upper case,
always the same colour for the same subsystem and the component sits beside it in the
ordinary text colour, so the pair reads as a place rather than as two tags. The tones
are the console's secondary palette only: green, amber and red mean good, look and wrong
on every other page and must not start meaning "playback" on this one. */
.logrow-place {
display: flex;
align-items: baseline;
gap: 5px;
min-width: 0;
}
.logrow-service {
flex: 0 0 auto;
max-width: 96px;
overflow: hidden;
color: var(--quiet);
font: 700 10px/1.4 var(--sans);
letter-spacing: .07em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.logrow-service[data-tone="info"] { color: var(--info-ink); }
.logrow-service[data-tone="note"] { color: var(--note-ink); }
.logrow-service[data-tone="data"] { color: var(--data-ink); }
.logrow-service[data-tone="idle"] { color: var(--idle-ink); }
.logrow-service[data-tone="quiet"] { color: var(--muted); }
.logrow-sep {
flex: 0 0 auto;
color: var(--line);
}
.logrow-component {
flex: 1 1 auto;
color: var(--quiet);
font-size: 11.5px;
}
/* The summary is the row. It is a button because selecting the row is what opens the
record, and the whole width of the column should be the target. */
.logrow-summary {
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
width: 100%;
height: 100%;
min-width: 0;
padding: 0;
overflow: hidden;
border: 0;
background: none;
font: inherit;
text-align: left;
}
.logrow-summary:hover:not(:disabled) {
border: 0;
background: none;
}
.logrow-summary:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.logrow-line {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.logrow-action {
flex: 0 0 auto;
color: var(--muted);
font: 600 10.5px/1.4 var(--mono);
letter-spacing: .04em;
}
.logrow-action[data-method="POST"],
.logrow-action[data-method="PUT"],
.logrow-action[data-method="PATCH"] { color: var(--info-ink); }
.logrow-action[data-method="DELETE"] { color: var(--danger-ink); }
.logrow-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
}
/* The reason a failure needs no drawer. */
.logrow-error {
overflow: hidden;
color: var(--danger-ink);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.logrow-context {
overflow: hidden;
color: var(--quiet);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A result is a word, not a badge. A success is understated to the point of being ignorable
which is the correct amount of attention for the four hundredth 200 in a row and only
a failure is given a fill. */
.logrow-result {
min-width: 0;
}
.logrow-verdict {
display: inline-block;
max-width: 100%;
color: var(--quiet);
font: 500 11px/1.5 var(--mono);
}
.logrow-verdict[data-tone="ok"] { color: var(--muted); }
.logrow-verdict[data-tone="info"] { color: var(--info-ink); }
.logrow-verdict[data-tone="data"] { color: var(--data-ink); }
.logrow-verdict[data-tone="warn"],
.logrow-verdict[data-tone="bad"] {
padding: 1px 6px;
border-radius: var(--radius-xs);
font-weight: 600;
}
.logrow-verdict[data-tone="warn"] {
background: var(--warn-wash);
color: var(--warn-ink);
}
.logrow-verdict[data-tone="bad"] {
background: var(--danger-wash);
color: var(--danger-ink);
}
.logrow-duration {
color: var(--quiet);
font: 11px/1 var(--mono);
font-variant-numeric: tabular-nums;
text-align: right;
}
.logrow-duration[data-tone="warn"] { color: var(--warn-ink); }
.logrow-duration[data-tone="bad"] {
color: var(--danger-ink);
font-weight: 600;
}
/* Offered only once the operator has left the tail, which is the only time following the
log automatically would be taking the page away from them. */
.logtail {
position: absolute;
right: 18px;
bottom: 14px;
z-index: 3;
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border: 1px solid var(--accent);
border-radius: 999px;
background: var(--surface-lift);
color: var(--accent-ink);
font: 600 11px/1 var(--sans);
box-shadow: 0 6px 16px rgba(0, 0, 0, .5);
}
.logtail svg {
width: 12px;
height: 12px;
transform: rotate(90deg);
}
.logtail:hover:not(:disabled) {
border-color: var(--accent);
background: var(--accent-wash);
color: var(--accent-ink);
}
/* The drawer answers "exactly how and why". Sections rather than one alphabetical list,
and a field with no value is omitted rather than printed as `unknown`. */
.logdrawer {
margin-top: 12px;
padding: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.log-inspector-head {
.logdrawer-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.log-inspector-head > div {
.logdrawer-head > div:first-child {
min-width: 0;
}
.log-inspector-head b,
.log-inspector-head span {
.logdrawer-head b {
display: block;
margin-top: 4px;
font-size: 14px;
overflow-wrap: anywhere;
}
.log-inspector-head span {
margin-top: 3px;
.logdrawer-place {
display: flex;
align-items: baseline;
gap: 5px;
margin: 0;
color: var(--quiet);
font: 11px/1.5 var(--mono);
font-size: 11px;
}
.log-inspector dl {
.logdrawer-error {
margin: 6px 0 0;
color: var(--danger-ink);
font: 12px/1.5 var(--mono);
overflow-wrap: anywhere;
}
.logdrawer-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
}
.logdrawer-grid {
display: grid;
grid-template-columns: minmax(130px, max-content) minmax(0, 1fr);
gap: 5px 14px;
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid var(--line);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px 24px;
margin-top: 12px;
}
.logdrawer-section h4 {
margin: 0 0 6px;
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .08em;
text-transform: uppercase;
}
.logdrawer-section dl {
display: grid;
grid-template-columns: minmax(88px, max-content) minmax(0, 1fr);
gap: 4px 12px;
margin: 0;
font: 12px/1.5 var(--mono);
}
.log-inspector dt { color: var(--muted); }
.log-inspector dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.logdrawer-section dt { color: var(--muted); }
.logdrawer-section dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.logdrawer-raw {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.logdrawer-raw summary {
cursor: pointer;
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .08em;
text-transform: uppercase;
}
.logdrawer-raw pre {
max-height: 320px;
margin: 10px 0 0;
overflow: auto;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-xs);
background: #080a0e;
font: 11px/1.6 var(--mono);
}
/* Columns are given up in the order they are least missed: the duration first, since it is
the only one of the six whose absence costs nothing to understanding what happened, and
the timestamp after it. Level, service and the summary are never dropped a log that
cannot say how bad, where, or what is not a log. */
@media (max-width: 1180px) {
.loghead,
.logrow {
grid-template-columns: 84px 46px minmax(130px, 170px) minmax(200px, 1fr) minmax(88px, 130px);
gap: 10px;
}
.loghead > :last-child,
.logrow-duration {
display: none;
}
.loghead,
.logbody {
min-width: 640px;
}
}
@media (max-width: 900px) {
.loghead,
.logrow {
grid-template-columns: 46px minmax(110px, 140px) minmax(180px, 1fr) minmax(72px, 110px);
gap: 8px;
}
.loghead > :first-child,
.logrow-time {
display: none;
}
.loghead,
.logbody {
min-width: 520px;
}
}
.logdetails {
min-width: 0;
}
@@ -2243,7 +2648,11 @@ pre.code {
letter-spacing: 0.02em;
}
/* The presence dot: three-valued, and the tone is passed rather than derived. */
/* The presence dot: four-valued, and the tone is passed rather than derived. The two
greens are the point one set is connected now and the other was an hour ago, and both
are fine, so they read as the same answer at two strengths rather than as two answers.
The dot is 7px and colour alone is never the whole signal here: every one of these
carries the wording in its title. */
.dot-state {
width: 7px;
height: 7px;
@@ -2254,6 +2663,9 @@ pre.code {
.dot-state[data-tone="ok"] {
background: var(--accent);
}
.dot-state[data-tone="idle"] {
background: var(--idle);
}
.dot-state[data-tone="warn"] {
background: var(--warn);
}
@@ -2873,18 +3285,14 @@ details summary {
min-height: 60px;
padding: 13px 0;
}
.loghead,
.logbody {
min-width: 680px;
}
.loghead,
.logline[data-virtual="true"] {
grid-template-columns: 128px 44px minmax(160px, 1fr) minmax(220px, 1.4fr);
gap: 10px;
}
/* The column collapse itself is width-driven and lives beside the table's own rules;
what a touch device needs on top of it is room to scroll and controls it can reach. */
.logview {
height: min(66dvh, 720px);
}
.logbar-actions {
margin-left: 0;
}
}
@media (max-width: 820px) {
+21 -10
View File
@@ -8,6 +8,10 @@ plugins {
id("androidx.baselineprofile")
}
// One Media3 version for every artifact in the build — see the dependency block for why
// the Jellyfin FFmpeg extension pins which line that can be.
val media3Version = "1.9.4"
// Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...).
// Blank means "no hardwired server": the setup screen asks the user for an address.
val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?).orEmpty().trim()
@@ -46,7 +50,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.74"
val defaultVersionName = "0.2.75"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -235,7 +239,7 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended")
// Compose for TV
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.tv:tv-material:1.1.0")
// Image loading
implementation("io.coil-kt:coil-compose:2.7.0")
@@ -248,20 +252,27 @@ dependencies {
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// DataStore (persisted settings)
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.datastore:datastore-preferences:1.2.1")
// Media3 / ExoPlayer for in-app playback
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
// One version for the whole Media3 line, including the Jellyfin FFmpeg extension below.
// That extension is compiled against media3-exoplayer and reached reflectively through
// EXTENSION_RENDERER_MODE_ON, so a core built from a different minor line fails at
// renderer construction rather than at compile time — and PlayerEngine's LinkageError
// fallback would swallow it, silently withdrawing surround software decode with nothing
// in the log to say why. Jellyfin publishes up to the 1.9 line, so that is the line this
// app is on; moving the core past it means finding a matching extension first.
implementation("androidx.media3:media3-exoplayer:$media3Version")
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
implementation("androidx.media3:media3-ui:$media3Version")
// Lets the player pull its bytes through the app's one OkHttp stack instead of
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
implementation("androidx.media3:media3-datasource-okhttp:$media3Version")
// Moonfin's Android TV backend keeps a software audio renderer behind Media3 so a
// surround track that is not bitstreamed is decoded to PCM instead of making the
// server re-encode the video beside it. This build targets Media3 1.5.x; Jellyfin's
// 1.5.0 extension is the matching published binary for that line.
// server re-encode the video beside it. This build targets Media3 1.9.x; Jellyfin's
// 1.9.0 extension is the matching published binary for that line.
//
// This is the ONLY native dependency the app carries, and it is 1.5MB per ABI because
// it links just the audio decoders it needs. It replaced a libmpv software-decoding
@@ -270,7 +281,7 @@ dependencies {
// the release APK from 3.1MB to 168MB and made every sideload a several-minute
// affair. A last-resort video path is not worth fifty times the app. If one is wanted
// again, it belongs behind a separately downloaded split, not in the base APK.
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
debugImplementation("androidx.compose.ui:ui-tooling")
@@ -16,7 +16,7 @@ class JourneyAnalytics(
private val userId: String,
private val now: () -> Long = System::currentTimeMillis,
private val journeyId: String = UUID.randomUUID().toString(),
) {
) : JourneySink {
private val lock = Any()
private val buffer = ArrayList<GatewayJourneyEvent>()
private var sequence = 0
@@ -27,16 +27,18 @@ class JourneyAnalytics(
// a process/session contribute exactly one home opening.
init { track("session", "home_open", screen = "home", feature = "app") }
fun track(
override fun track(
category: String,
action: String,
screen: String = "",
feature: String = "",
source: String = "",
target: String = "",
itemName: String = "",
itemType: String = "",
outcome: String = "",
screen: String,
feature: String,
source: String,
target: String,
itemId: String,
itemName: String,
itemType: String,
playSessionId: String,
outcome: String,
) = synchronized(lock) {
if (ended) return@synchronized
buffer += GatewayJourneyEvent(
@@ -49,12 +51,17 @@ class JourneyAnalytics(
feature = clean(feature),
source = clean(source),
target = clean(target),
itemId = clean(itemId),
itemName = cleanName(itemName),
itemType = clean(itemType),
// Cleaned like every other controlled field rather than trusted: a play session
// id is Emby's string, not ours, and the gateway rejects a whole event whose
// fields it cannot read. A session id is worth less than the step it describes.
playSessionId = clean(playSessionId),
outcome = clean(outcome),
occurredAt = timestamp(),
)
if (buffer.size > 200) buffer.removeAt(0)
if (buffer.size > MAX_BUFFERED_EVENTS) buffer.removeAt(0)
}
fun end(screen: String) = synchronized(lock) {
@@ -77,6 +84,14 @@ class JourneyAnalytics(
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
companion object {
/**
* The buffer is drained on a timer, but not while a viewer is in the player see
* [com.ponzischeme89.memby.ui.HomeViewModel.pauseAnalyticsForPlayback]. A Magic press
* can chain several films inside that pause, so this holds comfortably more than one
* playback's worth of steps and still drops the oldest rather than the newest.
*/
private const val MAX_BUFFERED_EVENTS = 200
private val iso8601 = object : ThreadLocal<SimpleDateFormat>() {
override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
@@ -0,0 +1,27 @@
package com.ponzischeme89.memby.data.analytics
/**
* Somewhere a journey step can be written down.
*
* It exists so that [PlaybackJourney]'s wording can be exercised against a real
* [JourneyAnalytics] in a unit test, and so that a caller which cannot see the collector
* `PlayerActivity`, which runs in its own activity and has no view model can write into
* the process-wide [JourneyTracker] through the same shape.
*
* Implementations must never block: this is called from the playback critical path.
*/
interface JourneySink {
fun track(
category: String,
action: String,
screen: String = "",
feature: String = "",
source: String = "",
target: String = "",
itemId: String = "",
itemName: String = "",
itemType: String = "",
playSessionId: String = "",
outcome: String = "",
)
}
@@ -0,0 +1,61 @@
package com.ponzischeme89.memby.data.analytics
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
/**
* The one journey a process is building, reachable from outside a view model.
*
* The collector used to be private to `HomeViewModel`, which meant the only thing that could
* record a step was the launcher and playback happens in a second activity. Everything the
* player does was therefore invisible: a Magic press starts a whole new film without the
* launcher ever hearing about it, and nothing at all said whether a playback the launcher had
* requested actually began.
*
* Held here rather than passed down because `PlayerActivity` has no owner in common with the
* launcher, and because it must land in the *same* journey: a viewer who watches three films
* in one sitting has had one app session, and starting a fresh journey id per activity would
* report it as three.
*
* Everything on it is a synchronised append to an in-memory list. Nothing here uploads, opens
* a connection or touches disk, which is what makes it safe to call from the moment a decoder
* is starting. The buffer is drained by `HomeViewModel` once the player has returned.
*/
object JourneyTracker : JourneySink {
@Volatile
private var analytics: JourneyAnalytics? = null
/**
* Starts the journey for a signed-in viewer and returns it, so its owner can drain it.
* Replacing the previous one is deliberate: a profile switch is a different person, and
* their steps must not be filed under the viewer who signed out.
*/
fun begin(userId: String): JourneyAnalytics =
JourneyAnalytics(userId).also { analytics = it }
override fun track(
category: String,
action: String,
screen: String,
feature: String,
source: String,
target: String,
itemId: String,
itemName: String,
itemType: String,
playSessionId: String,
outcome: String,
) {
// Silently nothing when no journey is open — the screensaver can start playback in a
// process where nobody has reached the launcher, and telemetry must never be a reason
// a television cannot play something.
analytics?.track(
category = category, action = action, screen = screen, feature = feature,
source = source, target = target, itemId = itemId, itemName = itemName,
itemType = itemType, playSessionId = playSessionId, outcome = outcome,
)
}
fun end(screen: String) { analytics?.end(screen) }
fun drain(): List<GatewayJourneyEvent> = analytics?.drain().orEmpty()
}
@@ -0,0 +1,84 @@
package com.ponzischeme89.memby.data.analytics
/**
* Where a playback was asked for.
*
* A journey is only worth reading if it says what somebody was *doing* when they pressed
* Play, and the launcher's row id is not that: it is an id the gateway happened to send, it
* differs between the gateway and the direct path, and a recommendation strip invents a new
* one every day. This is the small, closed vocabulary the analytics are grouped by instead
* stated by whatever started the playback, never inferred downstream, because by the time an
* event reaches the gateway the surface it came from is gone.
*
* [id] is the wire value and must stay stable: it is what the console groups on, so renaming
* one silently splits a household's history in two.
*/
enum class PlaybackEntryPoint(val id: String) {
CONTINUE_WATCHING("continue_watching"),
MAGIC_MOVIE("magic_movie"),
/** The player advancing to the episode after this one, by itself or by a press. */
NEXT_EPISODE("next_episode"),
HOME_HERO("home_hero"),
SEARCH("search"),
GENRE_BROWSER("genre_browser"),
CALENDAR("calendar"),
FAVOURITES("favourites"),
RECOMMENDATION("recommendation"),
/**
* A detail page reached by some route that did not name itself the "More like this"
* trail, a schedule card's series page, a page restored after the player returned.
*/
DETAIL_PAGE("detail_page"),
SCREENSAVER("screensaver"),
UNKNOWN("unknown"),
;
companion object {
/**
* Reads an entry point back off the wire. Unknown rather than null, because an id
* this build does not recognise is a television talking to a newer one, and a
* playback that cannot name where it came from is still a playback.
*/
fun fromId(id: String?): PlaybackEntryPoint =
entries.firstOrNull { it.id == id } ?: UNKNOWN
}
}
/**
* The entry point a launcher row stands for.
*
* Kind is asked first and id second, deliberately. The kind is the app's own classification
* and is the same on both paths, where the id is the gateway's Continue Watching is
* `continue` today and a household running an older gateway still sends `nextup`, which is
* the same shelf as far as a viewer is concerned. The id is only consulted for the three
* panes that are not rows at all.
*
* Pure so it can be tested: this is the function that decides whether a resume is filed
* under Continue Watching or lost in the general pile.
*/
fun playbackEntryPointFor(rowId: String?, rowKind: String? = null): PlaybackEntryPoint {
when (rowKind?.lowercase()) {
"continue", "nextup", "next_up" -> return PlaybackEntryPoint.CONTINUE_WATCHING
"favorites", "favourites" -> return PlaybackEntryPoint.FAVOURITES
}
return when (val id = rowId?.trim()?.lowercase().orEmpty()) {
"" -> PlaybackEntryPoint.DETAIL_PAGE
"continue", "nextup", "next-up" -> PlaybackEntryPoint.CONTINUE_WATCHING
"home-movie-hero" -> PlaybackEntryPoint.HOME_HERO
"search-results" -> PlaybackEntryPoint.SEARCH
"genre-browser-results" -> PlaybackEntryPoint.GENRE_BROWSER
"favorites" -> PlaybackEntryPoint.FAVOURITES
else -> when {
// Server-composed shelves: "for-you:pick-up", "because-you-watched:123",
// "highly-engaged". They are recommendations however they are named, and their
// ids are generated, so matching them individually would be a losing game.
id.startsWith("for-you") || id.startsWith("because-you-watched") ||
id.startsWith("recommend") || id.startsWith("highly-engaged") ->
PlaybackEntryPoint.RECOMMENDATION
id.startsWith("continue") -> PlaybackEntryPoint.CONTINUE_WATCHING
else -> PlaybackEntryPoint.DETAIL_PAGE
}
}
}
@@ -0,0 +1,145 @@
package com.ponzischeme89.memby.data.analytics
/**
* What a playback writes into the journey, in one place.
*
* It is one object rather than a handful of `trackJourney` calls spread across the launcher
* and the player because the two surfaces have to agree exactly. The console splits a
* viewer's session into separate viewing journeys at `playback`/`request`, groups them by the
* entry point in `source`, and decides the outcome from `playback`/`start`; a caller that got
* one of those three words slightly wrong would not fail, it would quietly produce a journey
* that reads as somebody jumping straight into a film from nowhere.
*
* Every category, action and outcome used here is inside the gateway's allowlist in
* `internal/api/analytics.go`. An event using a word that is not on it is dropped on arrival
* with nothing said, so adding one means adding it there too.
*/
object PlaybackJourney {
private const val CATEGORY = "playback"
private const val RECOMMENDATIONS = "recommendations"
private const val FEATURE = "playback"
private const val PLAYER = "player"
/** The feature name Magic's own steps are grouped under in the console. */
const val MAGIC_FEATURE = "magic_movie"
/**
* Somebody asked for this to play.
*
* The console cuts a session into viewing journeys here, so this is the step that must
* exist for *every* route into the player including the two that used to skip it
* entirely and [entryPoint] is the whole reason the resulting journey can say where it
* began.
*/
fun requested(
sink: JourneySink,
entryPoint: PlaybackEntryPoint,
screen: String,
itemId: String,
itemName: String,
itemType: String,
) = sink.track(
category = CATEGORY, action = "request", screen = screen, feature = FEATURE,
source = entryPoint.id, target = PLAYER,
itemId = itemId, itemName = itemName, itemType = itemType,
)
/**
* A picture is up: the negotiation, the launch and the decoder all worked.
*
* Recorded from the player rather than from whatever started it, because only the player
* knows whether anything actually happened and it carries [playSessionId], which is
* what ties this step to the stream Emby recorded on the other side.
*/
fun started(
sink: JourneySink,
entryPoint: PlaybackEntryPoint,
itemId: String,
itemName: String,
itemType: String,
playSessionId: String,
) = sink.track(
category = CATEGORY, action = "start", screen = PLAYER, feature = FEATURE,
source = entryPoint.id, target = PLAYER,
itemId = itemId, itemName = itemName, itemType = itemType,
playSessionId = playSessionId, outcome = "success",
)
/**
* It did not start, or it stopped being able to play.
*
* The same action as [started] wearing the other outcome, so "how often does playback from
* Continue Watching work" is one grouping rather than a comparison of two counts.
*/
fun failed(
sink: JourneySink,
entryPoint: PlaybackEntryPoint,
screen: String,
itemId: String,
itemName: String,
itemType: String,
playSessionId: String = "",
) = sink.track(
category = CATEGORY, action = "start", screen = screen, feature = FEATURE,
source = entryPoint.id, target = PLAYER,
itemId = itemId, itemName = itemName, itemType = itemType,
playSessionId = playSessionId, outcome = "failure",
)
/**
* This title's playback ended because the player moved on to another one.
*
* Deliberately not `stop`, which the launcher records once when the player hands the
* window back: a Magic press or an episode advance ends one title inside a player the
* viewer never left, and without this the films before the last one in a chain would have
* no ending at all.
*/
fun finished(
sink: JourneySink,
entryPoint: PlaybackEntryPoint,
itemId: String,
itemName: String,
itemType: String,
playSessionId: String,
completed: Boolean,
) = sink.track(
category = CATEGORY, action = "complete", screen = PLAYER, feature = FEATURE,
source = entryPoint.id, target = PLAYER,
itemId = itemId, itemName = itemName, itemType = itemType,
playSessionId = playSessionId,
outcome = if (completed) "completed" else "abandoned",
)
/** Magic was pressed: the viewer asked for something to be chosen for them. */
fun magicRequested(sink: JourneySink) = sink.track(
category = RECOMMENDATIONS, action = "request", screen = PLAYER,
feature = MAGIC_FEATURE, source = PLAYER, target = MAGIC_FEATURE,
)
/**
* Magic chose a film. Generation and selection are one step here because they are one
* press: nothing is offered to the viewer to accept or decline, the pick is announced and
* then played, and recording an "offered" the viewer never saw would invent a decision.
*/
fun magicSelected(
sink: JourneySink,
itemId: String,
itemName: String,
itemType: String,
) = sink.track(
category = RECOMMENDATIONS, action = "select", screen = PLAYER,
feature = MAGIC_FEATURE, source = MAGIC_FEATURE, target = PLAYER,
itemId = itemId, itemName = itemName, itemType = itemType, outcome = "success",
)
/**
* Magic had nothing to offer an older gateway with no route to answer, or a household
* that has run out of unseen library. Recorded because a press that produced nothing is
* the one thing the console could not otherwise tell from a button nobody uses.
*/
fun magicEmpty(sink: JourneySink) = sink.track(
category = RECOMMENDATIONS, action = "complete", screen = PLAYER,
feature = MAGIC_FEATURE, source = MAGIC_FEATURE, target = PLAYER,
outcome = "failure",
)
}
@@ -873,8 +873,17 @@ data class GatewayJourneyEvent(
val feature: String = "",
val source: String = "",
val target: String = "",
val itemId: String = "",
val itemName: String = "",
val itemType: String = "",
/**
* Emby's own id for the stream this step is about, on the playback steps that have one.
* It is what joins a journey to the gateway's playback log without the two having to
* agree on anything else, and it is deliberately not put in [target] that field is what
* the console builds its screen-to-screen path graph from, and a value unique per
* playback would fill it with rows nobody can read.
*/
val playSessionId: String = "",
val outcome: String = "",
val occurredAt: String = "",
)
@@ -8,6 +8,8 @@ import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.analytics.RowAnalytics
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
import com.ponzischeme89.memby.data.analytics.JourneySink
import com.ponzischeme89.memby.data.analytics.JourneyTracker
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError
import com.ponzischeme89.memby.data.model.BaseItem
@@ -170,7 +172,11 @@ 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())
// Registered process-wide rather than kept private, because the player is a second
// activity and everything it does — a Magic pick, a playback that actually started, one
// that failed — has to land in this same journey rather than nowhere.
private val journey: JourneyAnalytics =
JourneyTracker.begin(repository.currentSettings.userId.orEmpty())
@Volatile private var analyticsPausedForPlayback = false
init {
@@ -226,9 +232,21 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
fun trackJourney(
category: String, action: String, screen: String = "", feature: String = "",
source: String = "", target: String = "", itemName: String = "", itemType: String = "",
outcome: String = "",
) = journey.track(category, action, screen, feature, source, target, itemName, itemType, outcome)
source: String = "", target: String = "", itemId: String = "", itemName: String = "",
itemType: String = "", playSessionId: String = "", outcome: String = "",
) = journey.track(
category = category, action = action, screen = screen, feature = feature,
source = source, target = target, itemId = itemId, itemName = itemName,
itemType = itemType, playSessionId = playSessionId, outcome = outcome,
)
/**
* The collector itself, for the playback wording in
* [com.ponzischeme89.memby.data.analytics.PlaybackJourney]. Exposed rather than mirrored
* as another `trackJourney` overload so the launcher and the player write playback steps
* through exactly one set of functions.
*/
val journeySink: JourneySink get() = journey
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
@@ -127,6 +127,8 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow
@@ -1975,6 +1977,13 @@ private fun HomeScreen(
var resolvingItem by remember { mutableStateOf<BaseItem?>(null) }
var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
var returnItemId by rememberSaveable { mutableStateOf<String?>(null) }
// The shelf a Play press can be traced back to, as an entry point rather than as a row
// id. Kept beside [returnRowId] and written wherever it is, because the row's *kind* is
// the reliable half — a row id is the gateway's, differs on the direct path, and a
// recommendation strip invents a new one daily — and because by the time `playItem` runs
// the row list is out of scope. Saved with the rest so a recreate mid-browse does not
// file the next resume under nothing.
var returnRowKind by rememberSaveable { mutableStateOf<String?>(null) }
var recentSearches by remember { mutableStateOf<List<String>>(emptyList()) }
var initialSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
// Destination and row list states live above the conditional content branches.
@@ -2167,12 +2176,37 @@ private fun HomeScreen(
}
val playItem: (BaseItem) -> Unit = playItem@{ item ->
if (launchingItem != null || !item.membyPlayable) return@playItem
homeViewModel.trackJourney(
category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
feature = "playback", source = returnRowId.orEmpty(), target = "player",
itemName = item.name, itemType = item.type,
// The shelf this press traces back to, resolved once and then carried all the way
// into the player, so the step the launcher records and the step the player records
// agree about where the viewer came from. `source` used to be the raw row id, which
// the console could not group on: Continue Watching arrived as `continue` from the
// gateway, as `nextup` from a cached row, and as a recommendation id nobody had seen
// before every other day.
val entryPoint = playbackEntryPointFor(returnRowId, returnRowKind)
PlaybackJourney.requested(
sink = homeViewModel.journeySink,
entryPoint = entryPoint,
screen = selectedDestination.name.lowercase(),
itemId = item.id,
itemName = item.name,
itemType = item.type,
)
// Nothing here uploads: the buffer is drained once the player hands the window back.
homeViewModel.pauseAnalyticsForPlayback()
// Every way a launch can die before the player exists. The player records its own
// failures once it has one; these are the ones it would never hear about, and
// without them a journey ends on a request that appears simply to have gone nowhere.
val playbackScreen = selectedDestination.name.lowercase()
val recordPlaybackFailed = {
PlaybackJourney.failed(
sink = homeViewModel.journeySink,
entryPoint = entryPoint,
screen = playbackScreen,
itemId = item.id,
itemName = item.name,
itemType = item.type,
)
}
launchingItem = item
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
// Resuming: open the player now and let it resolve the stream while it starts.
@@ -2189,6 +2223,7 @@ private fun HomeScreen(
val request = repo.playbackRequest(item)
request to repo.readyPlayableForLaunch(request)
}.getOrElse {
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
@@ -2206,10 +2241,12 @@ private fun HomeScreen(
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
),
)
}
if (launched.isFailure) {
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
@@ -2273,10 +2310,12 @@ private fun HomeScreen(
playSessionId = playable.playSessionId,
playMethod = playable.playMethod,
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
),
)
}
if (launched.isFailure) {
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
@@ -2286,6 +2325,7 @@ private fun HomeScreen(
// A cancellation is the viewer having pressed Back out of the
// wait, which has already reopened the gate and said so on screen.
if (error is kotlinx.coroutines.CancellationException) throw error
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
// Nothing was launched, so nothing will come back to reopen the gate.
launchingItem = null
@@ -2535,6 +2575,11 @@ private fun HomeScreen(
savedFocus?.let { (rowId, itemId) ->
returnRowId = rowId
returnItemId = itemId
// Only the id was remembered, so the kind is cleared rather
// than left carrying whichever row was focused on the
// destination being left; the id alone still names the
// shelves that matter.
returnRowKind = null
}
if (
savedFocus != null &&
@@ -2623,6 +2668,7 @@ private fun HomeScreen(
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
returnRowId = SEARCH_ROW_ID
returnRowKind = null
returnItemId = item.id
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
homeViewModel.focusItem(item)
@@ -2705,6 +2751,7 @@ private fun HomeScreen(
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
returnRowId = GENRE_BROWSER_ROW_ID
returnRowKind = null
returnItemId = item.id
destinationFocus[BrowseDestination.GENRES] =
GENRE_BROWSER_ROW_ID to item.id
@@ -2744,6 +2791,7 @@ private fun HomeScreen(
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
returnRowId = GENRE_BROWSER_ROW_ID
returnRowKind = null
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackJourney(
@@ -2885,11 +2933,13 @@ private fun HomeScreen(
focusedHomeRowId = null
destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
returnRowId = HOME_HERO_ROW_ID
returnRowKind = null
returnItemId = item.id
homeViewModel.focusItem(item)
},
onItemSelected = { item ->
returnRowId = HOME_HERO_ROW_ID
returnRowKind = null
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackJourney(
@@ -3148,12 +3198,14 @@ private fun HomeScreen(
}
destinationFocus[selectedDestination] = row.id to item.id
returnRowId = row.id
returnRowKind = row.kind.name
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackRowFocused(row.id, row.kind.name, item.id)
},
onItemSelected = { item ->
returnRowId = row.id
returnRowKind = row.kind.name
returnItemId = item.id
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
homeViewModel.trackJourney(
@@ -3181,6 +3233,7 @@ private fun HomeScreen(
},
onItemLongPressed = { item ->
returnRowId = row.id
returnRowKind = row.kind.name
returnItemId = item.id
homeViewModel.focusItem(item)
if (item.membyPlayable) {
@@ -62,7 +62,9 @@ internal class MembyRenderersFactory(
}
return builder
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
// Renamed in Media3 1.9; the override parameter above keeps the framework's own
// spelling because it is the signature being overridden.
.setEnableAudioOutputPlaybackParameters(enableAudioTrackPlaybackParams)
.build()
}
}
@@ -70,9 +72,11 @@ internal class MembyRenderersFactory(
/**
* Builds the fixed-capabilities sink used by manual mode.
*
* Media3 1.5 ignores [DefaultAudioSink.Builder.setAudioCapabilities] when the builder has
* a Context, because the live route receiver replaces the supplied value. Its deprecated
* context-free builder is therefore the only 1.5 API that can honour a viewer override.
* Media3 ignores [DefaultAudioSink.Builder.setAudioCapabilities] when the builder has a
* Context, because the live route receiver replaces the supplied value. Its deprecated
* context-free builder is therefore the only API that can honour a viewer override. Still
* true on the 1.9 line; re-check it whenever the Media3 version moves, because the day it
* stops being true this whole function is dead code.
*/
@Suppress("DEPRECATION")
private fun fixedCapabilitiesAudioSinkBuilder(
@@ -0,0 +1,208 @@
package com.ponzischeme89.memby.ui.player
import android.os.SystemClock
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.preload.DefaultPreloadManager
import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.PreloadStatus
import androidx.media3.exoplayer.source.preload.PreloadException
import androidx.media3.exoplayer.source.preload.PreloadManagerListener
import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl
/**
* Opens the next episode's stream while the current one is still playing.
*
* The wait an advance costs is the same wait a cold start costs, and this app already knows
* where it goes: `prepare()` first frame is over 90% of it, and the largest single term
* inside that is the connection to Emby (see [PlayerEngine]). An advance is the one case where
* all of that can be paid *early* [NextUpResolver] knows what is next minutes before anybody
* presses anything, so by the time the credits roll the container header has been read, the
* tracks have been selected and a few seconds of media are in hand.
*
* Media3's [DefaultPreloadManager] does the work; this owns the two things it cannot decide for
* itself which episode is worth preloading ([preloadTargetFor]) and when a preloaded episode
* stops being the next one ([advanceTo]).
*
* Everything here degrades to nothing. A player built without a manager leaves [manager] null,
* every method becomes a no-op and [sourceFor] answers null, which puts [PlayerActivity] back
* on the ordinary `setMediaItem` path it used before any of this existed.
*/
@UnstableApi
internal class NextEpisodePreloader(
private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime,
) {
private class Entry(
val rank: Int,
val mediaItem: MediaItem,
/** Where the episode would resume, which is where preloading it is worth starting. */
val startPositionMs: Long,
val registeredAtMs: Long,
var readyAtMs: Long? = null,
)
private var manager: DefaultPreloadManager? = null
/** Keyed by Emby item id, which is what the rest of the player identifies an episode by. */
private val entries = LinkedHashMap<String, Entry>()
/**
* Where in this journey the episode on screen sits. It is a counter rather than a playlist
* index see [preloadTargetFor] so it only ever moves forward, and it is what both the
* target-status rule and the eviction rule are expressed against.
*/
private var playingRank = 0
/**
* Whether the episode now playing was started from preloaded work. Read by the trace, so
* `event=first_frame` can say which of the two latencies it is reporting.
*/
var startedFromPreload: Boolean = false
private set
/**
* Handed to [DefaultPreloadManager.Builder] before the manager exists, which is why this is
* a property of the preloader rather than something the engine writes: the rule has to read
* [playingRank], and [playingRank] moves for the life of the session.
*/
val statusControl = TargetPreloadStatusControl<Int, PreloadStatus> { rank ->
val target = preloadTargetFor(
rank = rank,
playingRank = playingRank,
startPositionMs = startPositionFor(rank),
)
// PRELOAD_STATUS_NOT_PRELOADED rather than null, which is the other way Media3 spells
// "skip this one": the package is @NonNullApi, so Kotlin reads the generic status as
// non-null and will not let this return null at all. The manager compares against this
// constant by value and calls onSkipped, so the two are the same instruction.
// A zero-length range would not do — it would still have the source prepared, which is
// most of what preloading costs.
if (!target.preload) PreloadStatus.PRELOAD_STATUS_NOT_PRELOADED
else PreloadStatus.specifiedRangeLoaded(target.startPositionMs, target.durationMs)
}
private val listener = object : PreloadManagerListener {
override fun onCompleted(mediaItem: MediaItem) {
val entry = entries.values.firstOrNull { it.mediaItem == mediaItem } ?: return
entry.readyAtMs = elapsedRealtime()
Log.i(
PLAYBACK_LOG_TAG,
"event=preload_ready rank=${entry.rank} " +
"tookMs=${(entry.readyAtMs ?: 0L) - entry.registeredAtMs} rangeMs=$PRELOAD_RANGE_MS",
)
}
override fun onError(error: PreloadException) {
// A failed preload costs nothing but the preload: the advance falls back to
// resolving the stream the way it always did. It is logged rather than surfaced
// for the reason row analytics are — nothing the viewer can act on.
val rank = entries.values.firstOrNull { it.mediaItem == error.mediaItem }?.rank
Log.w(
PLAYBACK_LOG_TAG,
"event=preload_failed rank=${rank ?: -1} reason=${error.message.orEmpty()}",
error,
)
}
}
/** Called once by [PlayerEngine], after the manager it shares components with is built. */
fun attach(manager: DefaultPreloadManager) {
this.manager = manager
manager.addListener(listener)
manager.setCurrentPlayingIndex(playingRank)
}
/**
* Registers what plays after the episode on screen, at one rank ahead of it.
*
* Called whenever [NextUpResolver] answers which happens once per episode and again on
* every re-negotiation of a stale stream, so a repeat for the same episode is ordinary and
* must not register it twice. A *different* episode for the same rank is the answer having
* changed underneath us (a re-resolve that moved), and replaces the old one.
*/
fun register(itemId: String, mediaItem: MediaItem, startPositionMs: Long = 0L) {
val active = manager ?: return
if (itemId.isBlank()) return
val rank = playingRank + 1
val existing = entries[itemId]
if (existing != null && existing.rank == rank && existing.mediaItem == mediaItem) return
// Anything else already sitting at this rank is a superseded answer. It is safe to
// remove because nothing has been handed to the player from it: only advanceTo does
// that, and it moves the rank on as it does.
entries.entries.filter { it.value.rank == rank && it.key != itemId }.forEach { (id, entry) ->
active.remove(entry.mediaItem)
entries.remove(id)
}
existing?.let { active.remove(it.mediaItem) }
entries[itemId] = Entry(rank, mediaItem, startPositionMs.coerceAtLeast(0L), elapsedRealtime())
active.add(mediaItem, rank)
active.invalidate()
Log.i(PLAYBACK_LOG_TAG, "event=preload_registered rank=$rank held=${entries.size}")
}
/**
* The preloaded source for [mediaItem], or null if there is none to reuse.
*
* Null is the ordinary answer on a cold start, on the direct-to-Emby path, on a retry with
* a freshly negotiated URL, and any time the preload simply did not finish all of which
* put [PlayerActivity] back on `setMediaItem`.
*/
fun sourceFor(mediaItem: MediaItem): MediaSource? = manager?.getMediaSource(mediaItem)
/**
* Moves the journey on to [itemId], which is now the episode playing.
*
* Everything strictly behind it is released ([obsoletePreloadRanks]); the entry for
* [itemId] itself is deliberately kept, because the player has just been handed its source
* and removing it from the manager would release that source underneath the decoder. Its
* target status becomes "not preloaded" on the next [DefaultPreloadManager.invalidate],
* which stops the loading without touching what the player holds.
*/
fun advanceTo(itemId: String, startedFromPreload: Boolean) {
this.startedFromPreload = startedFromPreload
val active = manager ?: return
val entry = entries[itemId]
playingRank = entry?.rank ?: (playingRank + 1)
obsoletePreloadRanks(entries.values.map { it.rank }, playingRank).forEach { rank ->
entries.entries.filter { it.value.rank == rank }.forEach { (id, stale) ->
active.remove(stale.mediaItem)
entries.remove(id)
}
}
active.setCurrentPlayingIndex(playingRank)
active.invalidate()
Log.i(
PLAYBACK_LOG_TAG,
"event=preload_advanced rank=$playingRank preloaded=$startedFromPreload held=${entries.size}",
)
}
/**
* Throws away every preloaded source, for a journey that is no longer the one they belong
* to a trailer, a Magic pick, a next-episode preview, an interruption for maintenance, or
* a retry that had to re-negotiate the stream. Each of those makes the episode this was
* counting on either wrong or unreachable, and holding a prepared source for it would keep
* a connection open to Emby for something nobody is going to watch.
*/
fun reset() {
startedFromPreload = false
val active = manager ?: return
if (entries.isEmpty()) return
entries.clear()
active.reset()
Log.i(PLAYBACK_LOG_TAG, "event=preload_reset")
}
fun release() {
entries.clear()
manager?.let {
it.removeListener(listener)
it.release()
}
manager = null
}
private fun startPositionFor(rank: Int): Long =
entries.values.firstOrNull { it.rank == rank }?.startPositionMs ?: 0L
}
@@ -28,6 +28,16 @@ internal class NextUpResolver(
private val elapsedRealtime: () -> Long,
/** Injected so a test needs no repository, and so the direct/gateway split stays put. */
private val fetch: suspend (itemId: String) -> NextEpisode?,
/**
* Called on the resolver's own thread every time an answer is *published* the first
* lookup, and again whenever [warm] or [playable] re-negotiates a stale stream.
*
* It exists for [NextEpisodePreloader], which has to hear about the second of those as well
* as the first: a re-negotiated episode carries a new URL, so preloaded work keyed on the
* old one is no longer the thing the player will be handed. Defaulting to nothing keeps
* every existing test construction of this class untouched.
*/
private val onResolved: (NextEpisode?) -> Unit = {},
) {
/** Guards the whole of [resolve]: two callers must never produce two requests. */
private val lock = Mutex()
@@ -97,13 +107,19 @@ internal class NextUpResolver(
// would put up a banner promising something that cannot be played and then, the
// countdown having run out, swap it in automatically and fail.
?.takeIf { it.url.isNotBlank() }
lock.withLock {
if (subjectId == itemId) {
val published = lock.withLock {
val current = subjectId == itemId
if (current) {
answer = resolved
resolvedAt = elapsedRealtime()
}
inFlight = null
current
}
// Only for the episode still being played. An answer that arrived after the viewer
// moved on describes a journey that no longer exists, and announcing it would have the
// preloader open a connection for it.
if (published) onResolved(resolved)
pending.complete(resolved)
return resolved
}
@@ -1,5 +1,13 @@
package com.ponzischeme89.memby.ui.player
/**
* The one logcat tag everything on the playback path writes under, so `adb logcat -s
* MembyPlayback` is the whole diagnosis. Top-level rather than private to [PlayerActivity]
* because the pieces the activity delegates to [NextEpisodePreloader], [PlayerEngine] have
* to land in the same stream to be readable beside it.
*/
internal const val PLAYBACK_LOG_TAG = "MembyPlayback"
/**
* Times the stages between pressing Play and seeing a frame.
*
@@ -65,6 +65,9 @@ import com.ponzischeme89.memby.diagnostics.MembyDiagnostics
import com.ponzischeme89.memby.data.audioPassthroughPreference
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.analytics.JourneyTracker
import com.ponzischeme89.memby.data.analytics.PlaybackEntryPoint
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
@@ -163,6 +166,26 @@ class PlayerActivity : ComponentActivity() {
private var playSessionId = ""
private var playMethod = "DirectPlay"
private var stoppedInBackground = false
/**
* Where the title on screen was asked for, so the player's own journey steps can say so.
*
* It arrives on the intent for a launch from the launcher and is set here for the two the
* launcher never sees a Magic press and an episode advance, both of which start a whole
* new playback inside a player the viewer never left. Those were the entry points that
* produced no journey at all: the launcher recorded a request for the film somebody
* originally chose and then nothing until the window came back, hours and several titles
* later.
*/
private var journeyEntryPoint = PlaybackEntryPoint.UNKNOWN
/**
* Whether this title's failure has been recorded. A playback error pane can be raised
* several times over one title every automatic retry that gives up ends here and the
* console counts starts against failures, so a title that failed once must contribute
* one. Cleared wherever [playbackStarted] is, because that is where the title changes.
*/
private var journeyFailureRecorded = false
private var availableSubtitles: List<PlayableSubtitle> = emptyList()
private var encodedSubtitleId: String? = null
private var remainingView: TextView? = null
@@ -317,9 +340,25 @@ class PlayerActivity : ComponentActivity() {
scope = lifecycleScope,
elapsedRealtime = SystemClock::elapsedRealtime,
fetch = { id -> ServiceLocator.repository.nextEpisode(id, seriesId = null) },
onResolved = ::preloadNextEpisode,
)
}
private val nextEpisode: NextEpisode? get() = nextUpResolver.current
/**
* Opens the next episode's stream while this one is still playing, so an advance does not
* pay the connection, the container header and the seek index over again. Built beside the
* player because the two share their renderers, load control and playback looper; null
* until [onCreate] has built them, and inert on any television where it could not be.
*/
private var preloader: NextEpisodePreloader? = null
/**
* Whether the stream now playing came from preloaded work. Set by [startMedia] and read by
* the `first_frame` line, which is the only place the two latencies this feature exists to
* separate a cold start and a preloaded start can be told apart in a log.
*/
private var startedFromPreloadedSource = false
private var returningHomeAfterCompletion = false
private var nextUpJob: Job? = null
private var nextEpisodeLookupJob: Job? = null
@@ -527,6 +566,13 @@ class PlayerActivity : ComponentActivity() {
return
}
// Saved state first, like every other value here: a configuration change is the same
// playback, and re-reading the intent would be correct anyway — but a Magic press
// arrives as a *new* intent, so the two must not be able to disagree.
journeyEntryPoint = PlaybackEntryPoint.fromId(
savedInstanceState?.getString(STATE_JOURNEY_SOURCE)
?: intent.getStringExtra(EXTRA_JOURNEY_SOURCE),
)
itemId = savedInstanceState?.getString(STATE_ITEM_ID)
?: intent.getStringExtra(EXTRA_ITEM_ID)
mediaSourceId = savedInstanceState?.getString(STATE_MEDIA_SOURCE_ID)
@@ -680,8 +726,8 @@ class PlayerActivity : ComponentActivity() {
// first one cannot arrive until onCreate has returned.
// Surround passthrough is settled before the sink is built, not after: an audio
// sink cannot change its mind about bitstreaming a format once a track is open.
val createdPlayer = runCatching {
PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference)
val createdSession = runCatching {
PlayerEngine.createSession(this, ServiceLocator.settings.current.audioPassthroughPreference)
}.getOrElse { error ->
Log.e(PLAYBACK_LOG_TAG, "event=player_create_failed", error)
prerollActive = false
@@ -700,7 +746,8 @@ class PlayerActivity : ComponentActivity() {
)
return
}
player = createdPlayer.also { playback ->
preloader = createdSession.preloader
player = createdSession.player.also { playback ->
view.player = playback
trace.mark(PlaybackTrace.PLAYER_BUILT)
// Audio start is not on Player.Listener — only the analytics interface
@@ -842,6 +889,7 @@ class PlayerActivity : ComponentActivity() {
Log.i(
PLAYBACK_LOG_TAG,
"event=first_frame item=${itemId.orEmpty()} totalMs=$firstFrameMs " +
"start=${if (startedFromPreloadedSource) "preloaded" else "cold"} " +
"resumeMs=$initialResumePositionMs ${trace.summary()}",
)
}
@@ -922,13 +970,25 @@ class PlayerActivity : ComponentActivity() {
val playback = player ?: return
currentMediaUrl = url
currentMediaSubtitles = subtitles
startedFromPreloadedSource = false
val prepared = runCatching {
require(url.isNotBlank()) { "Playback URL is blank" }
val item = mediaItem(url, subtitles)
// A source the preload manager already prepared, if this is the episode it was
// told to expect. Null on every other launch — a cold start, a retry that
// re-negotiated the URL, the direct-to-Emby path, a preload that did not finish —
// and null is simply the path the player took before any of this existed.
val preloaded = preloader?.sourceFor(item)
// A retry must not inherit a wedged loader, extractor or decoder. This is the
// in-process equivalent of the state reset an app restart used to provide.
playback.stop()
playback.clearMediaItems()
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
if (preloaded != null) {
startedFromPreloadedSource = true
playback.setMediaSource(preloaded, positionMs.coerceAtLeast(0L))
} else {
playback.setMediaItem(item, positionMs.coerceAtLeast(0L))
}
playback.playWhenReady = playWhenReady
playback.prepare()
}
@@ -1549,6 +1609,11 @@ class PlayerActivity : ComponentActivity() {
private fun startPlaybackSession(playback: Player) {
if (playbackStarted) return
playbackStarted = true
// First, because it is the answer to "did the thing the viewer asked for happen",
// and last, because everything below it is a request. It is a synchronised append to
// an in-memory list — no upload, no disk — so it cannot delay a decoder that has just
// produced a picture; the buffer is drained by the launcher when the window returns.
recordPlaybackStartedJourney()
if (!prerollActive) showPlaybackIdentity()
// Deliberately here rather than in onCreate. Nothing needs the cast until the
// viewer opens the overlay, and on a series the launcher does not yet know which
@@ -1969,6 +2034,7 @@ class PlayerActivity : ComponentActivity() {
}
private fun showPlaybackError(failure: PlaybackFailure) {
recordPlaybackFailedJourney()
hidePlaybackLoading()
playerView?.hideController()
errorTitleView?.text = failure.title
@@ -3058,6 +3124,67 @@ class PlayerActivity : ComponentActivity() {
private val playingAnEpisode: Boolean
get() = !playbackSeriesName.isNullOrBlank() || prerollEpisodeCode.isNotBlank()
/**
* Whether the journey should hear about what is on screen at all.
*
* A trailer and a next-episode preview both run through the same player and the same
* start and error paths, and neither is a viewing: recorded, they would double the
* playback counts and give a household a "watched" outcome for a film nobody put on.
* The launcher records the trailer press itself, which is the part worth knowing.
*/
private val journeyRecordsPlayback: Boolean
get() = pendingTrailerRequest == null && !playingNextEpisodePreview
/** Read off the series name, the same evidence [playingAnEpisode] uses. */
private val journeyItemType: String
get() = if (playingAnEpisode) "Episode" else "Movie"
private fun recordPlaybackStartedJourney() {
if (!journeyRecordsPlayback) return
journeyFailureRecorded = false
PlaybackJourney.started(
sink = JourneyTracker,
entryPoint = journeyEntryPoint,
itemId = itemId.orEmpty(),
itemName = playbackTitle,
itemType = journeyItemType,
playSessionId = playSessionId,
)
}
private fun recordPlaybackFailedJourney() {
if (!journeyRecordsPlayback || journeyFailureRecorded) return
journeyFailureRecorded = true
PlaybackJourney.failed(
sink = JourneyTracker,
entryPoint = journeyEntryPoint,
screen = "player",
itemId = itemId.orEmpty(),
itemName = playbackTitle,
itemType = journeyItemType,
playSessionId = playSessionId,
)
}
/**
* The title on screen is being replaced by another one inside this same player a Magic
* pick or an episode advance. Without it the films before the last one in a chain would
* have a request and a start and no ending at all, because the launcher's own `stop` is
* recorded once, when the window finally comes back.
*/
private fun recordPlaybackFinishedJourney(completed: Boolean) {
if (!journeyRecordsPlayback || !playbackStarted) return
PlaybackJourney.finished(
sink = JourneyTracker,
entryPoint = journeyEntryPoint,
itemId = itemId.orEmpty(),
itemName = playbackTitle,
itemType = journeyItemType,
playSessionId = playSessionId,
completed = completed,
)
}
/**
* Puts the two optional transport controls in step with what is actually known.
*
@@ -3091,6 +3218,7 @@ class PlayerActivity : ComponentActivity() {
private fun playSomethingElse() {
if (magicJob?.isActive == true || advanceRequested || advancing) return
val current = itemId?.takeIf { it.isNotBlank() }
PlaybackJourney.magicRequested(JourneyTracker)
magicJob = lifecycleScope.launch {
showPlaybackLoading(
title = getString(R.string.player_magic_finding),
@@ -3107,6 +3235,7 @@ class PlayerActivity : ComponentActivity() {
// An older gateway has no route to answer with, and a household that has run
// out of unseen library has no answer to give. Neither is worth an error pane
// over somebody's film: say so, put the picture back and withdraw the button.
PlaybackJourney.magicEmpty(JourneyTracker)
magicAvailable = false
updateNextEpisodeButton()
hidePlaybackLoading()
@@ -3116,6 +3245,26 @@ class PlayerActivity : ComponentActivity() {
}
magicOffered += pick.itemId
if (magicOffered.size > MAGIC_MEMORY) magicOffered.removeAt(0)
// The whole sequence, in the order it happened: the film that was on ends, the
// recommendation is recorded as chosen, and the request for the new one carries
// magic_movie as its entry point — which is what makes the console cut a fresh
// viewing journey here and attribute it to the button rather than to whatever
// shelf the *previous* film came from hours ago.
recordPlaybackFinishedJourney(completed = false)
PlaybackJourney.magicSelected(
sink = JourneyTracker,
itemId = pick.itemId,
itemName = pick.title,
itemType = pick.itemType,
)
PlaybackJourney.requested(
sink = JourneyTracker,
entryPoint = PlaybackEntryPoint.MAGIC_MOVIE,
screen = "player",
itemId = pick.itemId,
itemName = pick.title,
itemType = pick.itemType,
)
Toast.makeText(
this@PlayerActivity,
getString(R.string.player_magic_selected, pick.title),
@@ -3142,6 +3291,7 @@ class PlayerActivity : ComponentActivity() {
logoUrl = pick.logoUrl,
),
backdropUrl = pick.backdropUrl,
journeySource = PlaybackEntryPoint.MAGIC_MOVIE.id,
),
)
}
@@ -3849,6 +3999,29 @@ class PlayerActivity : ComponentActivity() {
}
}
/**
* Tells the preloader what plays next, every time the resolver publishes an answer.
*
* Deliberately hung off the resolver rather than called from one place: the answer arrives
* once when playback settles and again whenever a stale stream is re-negotiated, and a
* re-negotiation is exactly when preloaded work stops matching what the player will be
* handed. A null answer a film, the last episode of a season leaves the preloader with
* nothing registered, which is the correct amount of work to do for it.
*
* Nothing here is allowed to interrupt playback: a preview, a trailer or a Magic pick is a
* different journey, and the episode this would register is not the one that would play.
*/
private fun preloadNextEpisode(next: NextEpisode?) {
val active = preloader ?: return
if (playingNextEpisodePreview || pendingTrailerRequest != null) return
if (next == null || next.url.isBlank() || next.itemId.isBlank()) return
active.register(
itemId = next.itemId,
mediaItem = mediaItem(next.url, next.subtitles),
startPositionMs = next.resumePositionMs,
)
}
private fun playNext(next: NextEpisode) {
if (advancing || returningHomeAfterCompletion) return
advancing = true
@@ -3895,6 +4068,11 @@ class PlayerActivity : ComponentActivity() {
)
}
// Recorded before the fields below move, or it would describe the incoming episode.
// Completed rather than abandoned: every trigger that reaches here is somebody
// choosing to go on to the next one, not leaving this one.
recordPlaybackFinishedJourney(completed = true)
itemId = next.itemId
mediaSourceId = next.mediaSourceId
playSessionId = next.playSessionId
@@ -3909,6 +4087,19 @@ class PlayerActivity : ComponentActivity() {
encodedSubtitleId = null
stopReported = false
playbackStarted = false
// The episode after this one was not asked for on the shelf the first one came from,
// so the journey's entry point moves with the title. Recorded here rather than left
// to the launcher, which cannot see an advance at all.
journeyEntryPoint = PlaybackEntryPoint.NEXT_EPISODE
journeyFailureRecorded = false
PlaybackJourney.requested(
sink = JourneyTracker,
entryPoint = PlaybackEntryPoint.NEXT_EPISODE,
screen = "player",
itemId = next.itemId,
itemName = nextTitle(next),
itemType = "Episode",
)
playbackIdentityShown = false
playbackIdentityHideJob?.cancel()
playbackIdentityView?.apply {
@@ -3973,6 +4164,11 @@ class PlayerActivity : ComponentActivity() {
if (playback != null) {
trace.mark(PlaybackTrace.STREAM_RESOLVED)
startMedia(next.url, next.subtitles, next.resumePositionMs, playWhenReady = true)
// Strictly after startMedia, never before it. Moving the journey on releases every
// source behind the one now playing — including the episode that was on screen a
// moment ago — and that episode's source is only safe to release once the player
// has been handed its replacement.
preloader?.advanceTo(next.itemId, startedFromPreload = startedFromPreloadedSource)
}
advancing = false
}
@@ -3986,6 +4182,11 @@ class PlayerActivity : ComponentActivity() {
private fun interruptForMaintenance() {
player?.pause()
progressJob?.cancel()
// The gateway has gone into maintenance, so the episode this was holding open a
// connection for is not going to be played from here. finish() releases it a moment
// later anyway; letting it go now means the connection is not held across the
// transition back to the launcher.
preloader?.reset()
startActivity(
Intent(this, MainActivity::class.java).addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
@@ -4969,6 +5170,7 @@ class PlayerActivity : ComponentActivity() {
outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId)
outState.putString(STATE_PLAY_SESSION_ID, playSessionId)
outState.putString(STATE_PLAY_METHOD, playMethod)
outState.putString(STATE_JOURNEY_SOURCE, journeyEntryPoint.id)
val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles
if (savedSubtitles.isNotEmpty()) {
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles))
@@ -5128,6 +5330,11 @@ class PlayerActivity : ComponentActivity() {
)
}
playerView?.player = null
// Before the player, not after: the manager holds sources the player is still attached
// to, and releasing it second would leave preloading running against a released
// playback looper for as long as it took to notice.
preloader?.release()
preloader = null
playback?.release()
player = null
super.onDestroy()
@@ -5312,6 +5519,13 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
private const val EXTRA_PLAY_METHOD = "extra_play_method"
/**
* The surface the viewer pressed Play on, carried in so the player's own journey
* steps can name it. There are two launch forms and both must set it a field with
* no `putExtra`/`getExtra` pair takes its default silently, which is exactly how
* three "is this worth asking the backend" booleans once shipped switched off.
*/
private const val EXTRA_JOURNEY_SOURCE = "extra_journey_source"
private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request"
private const val EXTRA_TRAILER_REQUEST = "extra_trailer_request"
private const val EXTRA_TRAILER_UNAVAILABLE = "extra_trailer_unavailable"
@@ -5339,6 +5553,7 @@ class PlayerActivity : ComponentActivity() {
private const val STATE_RUNTIME_MS = "state_runtime_ms"
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
private const val STATE_MAGIC_OFFERED = "state_magic_offered"
private const val STATE_JOURNEY_SOURCE = "state_journey_source"
private const val PLAYER_PREFERENCES = "player_preferences"
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
private const val PICTURE_MODE_KEY = "picture_mode"
@@ -5365,8 +5580,10 @@ class PlayerActivity : ComponentActivity() {
posterUrl: String? = null,
backdropUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
journeySource: String = PlaybackEntryPoint.UNKNOWN.id,
): Intent = Intent(context, PlayerActivity::class.java).apply {
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
putExtra(EXTRA_JOURNEY_SOURCE, journeySource)
putExtra(EXTRA_ITEM_ID, request.itemId)
putExtra(EXTRA_TITLE, request.title)
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
@@ -5428,8 +5645,10 @@ class PlayerActivity : ComponentActivity() {
playSessionId: String = "",
playMethod: String = "DirectPlay",
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
journeySource: String = PlaybackEntryPoint.UNKNOWN.id,
): Intent =
Intent(context, PlayerActivity::class.java).apply {
putExtra(EXTRA_JOURNEY_SOURCE, journeySource)
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
putExtra(EXTRA_URL, url)
putExtra(EXTRA_TITLE, title)
@@ -5619,7 +5838,6 @@ class PlayerActivity : ComponentActivity() {
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
private const val STABLE_PLAYBACK_RESET_MS = 30_000L
private const val PROLONGED_REBUFFER_RECOVERY_MS = 12_000L
private const val PLAYBACK_LOG_TAG = "MembyPlayback"
private fun playbackStateName(state: Int): String =
when (state) {
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.util.Log
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
@@ -10,7 +11,9 @@ import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.preload.DefaultPreloadManager
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.remote.HttpStack
@@ -33,6 +36,30 @@ import java.util.concurrent.TimeUnit
@UnstableApi
internal object PlayerEngine {
/**
* Two independent switches, so a television that misbehaves on one of these can have it
* taken away without losing the other or the upgrade underneath them both.
*
* [PRELOADING_ENABLED] off leaves the player exactly as it was before preloading existed:
* [Session.preloader] is unattached, every call on it is a no-op and `sourceFor` answers
* null, which is the same path a cold start already takes. [DYNAMIC_SCHEDULING_ENABLED] is
* Media3's own experimental scheduling, which wakes the playback loop when there is work
* rather than on a fixed cadence; it is the half most likely to interact badly with a
* vendor decoder, and it is the half that can be dropped with no behaviour change at all.
*/
const val PRELOADING_ENABLED = true
const val DYNAMIC_SCHEDULING_ENABLED = true
/**
* The player and, when preloading is on, the manager that shares its components.
*
* They are returned together because they are built together and must not be built twice:
* [DefaultPreloadManager.Builder.buildExoPlayer] is what guarantees the preloaded source
* and the player agree about the renderers, the load control, the track selector, the
* bandwidth meter and the one that would actually break the playback looper.
*/
class Session(val player: ExoPlayer, val preloader: NextEpisodePreloader)
/**
* [audio] decides whether a surround track is bitstreamed to the receiver or decoded
* here into PCM. It defaults to automatic, which is also the right answer for the
@@ -41,43 +68,102 @@ internal object PlayerEngine {
fun create(
context: Context,
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
): ExoPlayer = try {
buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
): ExoPlayer = createSession(context, audio, preloading = false).player
/**
* The main player, with next-episode preloading attached where it can be.
*
* Falls back to a plain player on any failure to build the manager rather than failing the
* launch: preloading is a saving, and a television that cannot have it must still play.
*/
fun createSession(
context: Context,
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
preloading: Boolean = PRELOADING_ENABLED,
): Session = try {
buildSession(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON, preloading)
} catch (_: RuntimeException) {
// Extension construction happens while ExoPlayer is built. A binary mismatch or
// broken vendor audio implementation must degrade to Android's renderers rather
// than make Memby close during its process-wide pre-roll warm-up.
buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
buildSession(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, preloading)
} catch (_: LinkageError) {
buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
buildSession(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, preloading)
}
private fun buildPlayer(
private fun buildSession(
context: Context,
audio: AudioPassthroughPreference,
extensionRendererMode: Int,
): ExoPlayer = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory(context))
.setRenderersFactory(
MembyRenderersFactory(context, audio)
// Some Android TV firmwares advertise a preferred hardware decoder which
// fails only after initialization. Let Media3 try another installed decoder
// before declaring the file unsupported.
.setEnableDecoderFallback(true)
// The bundled FFmpeg audio renderer sits after platform decoders. It is
// reached only when Android cannot decode a surround format itself; its
// output is PCM, so passthrough-capable tracks still bypass it untouched.
.setExtensionRendererMode(extensionRendererMode),
preloading: Boolean,
): Session {
val preloader = NextEpisodePreloader()
val renderers = renderersFactory(context, audio, extensionRendererMode)
val sources = mediaSourceFactory(context)
// Everything the two share is set on the *preload manager's* builder, not on the
// ExoPlayer one: buildExoPlayer overwrites the media source factory, renderers, load
// control, bandwidth meter, track selector and playback looper on whatever builder it
// is handed. Setting them twice would be harmless but misleading — the ExoPlayer
// builder below carries only what the manager has no opinion about.
val player = if (preloading) {
runCatching {
val managerBuilder = DefaultPreloadManager.Builder(context, preloader.statusControl)
.setMediaSourceFactory(sources)
.setRenderersFactory(renderers)
.setTrackSelectorFactory(TrackSelector.Factory { DefaultTrackSelector(it) })
.setLoadControl(loadControl())
// Manager first, then the player from the same builder — the order Media3's
// own documentation uses, and the one that guarantees the shared playback
// looper exists before anything is handed to it.
preloader.attach(managerBuilder.build())
managerBuilder.buildExoPlayer(exoPlayerBuilder(context))
}.onFailure { error ->
// Named rather than swallowed. The fallback below is silent from the viewer's
// side — playback works exactly as it did — so without this line a television
// that never preloads anything is indistinguishable from one where the feature
// is working and simply never saves any time.
Log.w(PLAYBACK_LOG_TAG, "event=preload_unavailable reason=${error.javaClass.simpleName}", error)
}.getOrNull()
} else {
null
} ?: exoPlayerBuilder(context)
.setMediaSourceFactory(sources)
.setRenderersFactory(renderers)
.setTrackSelector(DefaultTrackSelector(context))
.setLoadControl(loadControl())
.build()
player.setAudioAttributes(
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
/* handleAudioFocus = */ false,
)
.setTrackSelector(DefaultTrackSelector(context))
.setLoadControl(loadControl())
.build()
.apply {
setAudioAttributes(
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
/* handleAudioFocus = */ false,
)
}
return Session(player, preloader)
}
/**
* Carries only what [DefaultPreloadManager.Builder.buildExoPlayer] does not overwrite.
*
* `enablePerStreamMediaProgression` belongs here and is deliberately absent: it arrived in
* Media3 1.11, and this app is pinned to the 1.9 line by the Jellyfin FFmpeg extension (see
* `app/build.gradle.kts`). Dynamic scheduling is the part of that same work which *is*
* available here, and it is behind its own switch.
*/
private fun exoPlayerBuilder(context: Context) = ExoPlayer.Builder(context)
.experimentalSetDynamicSchedulingEnabled(DYNAMIC_SCHEDULING_ENABLED)
private fun renderersFactory(
context: Context,
audio: AudioPassthroughPreference,
extensionRendererMode: Int,
) = MembyRenderersFactory(context, audio)
// Some Android TV firmwares advertise a preferred hardware decoder which
// fails only after initialization. Let Media3 try another installed decoder
// before declaring the file unsupported.
.setEnableDecoderFallback(true)
// The bundled FFmpeg audio renderer sits after platform decoders. It is
// reached only when Android cannot decode a surround format itself; its
// output is PCM, so passthrough-capable tracks still bypass it untouched.
.setExtensionRendererMode(extensionRendererMode)
private fun mediaSourceFactory(context: Context) = DefaultMediaSourceFactory(
// DefaultDataSource still handles the non-HTTP schemes (file:, asset:, content:);
@@ -0,0 +1,71 @@
package com.ponzischeme89.memby.ui.player
/**
* What, if anything, the preload manager should load ahead for one registered episode.
*
* Kept apart from [NextEpisodePreloader] and free of every Media3 type so it can be tested as
* plain JUnit, the rule this repository applies to anything worth pinning. The decisions here
* are the only ones that bound what preloading costs, and they are the half that a unit test
* can actually check whether ExoPlayer then honours the bound is a question for the device.
*/
internal data class PreloadTarget(
/** False means "leave this one alone": the manager holds it but loads nothing for it. */
val preload: Boolean,
val startPositionMs: Long = 0L,
val durationMs: Long = 0L,
)
/**
* Ranking data is a monotonically increasing position in *this playback journey* the episode
* playing now, then the one after it, then the one after that rather than an index into a
* playlist. Nothing here is a playlist: the player is handed one episode at a time and the next
* one is discovered while it plays, so a rank is only ever assigned when an answer arrives.
*
* Exactly one episode ahead is ever worth preloading. Two would double the cost for a title the
* viewer has two episodes' worth of time to walk away from, and this app ships to boxes where
* the memory matters more than the second hop does.
*/
internal fun preloadTargetFor(
rank: Int,
playingRank: Int,
startPositionMs: Long = 0L,
rangeMs: Long = PRELOAD_RANGE_MS,
): PreloadTarget =
if (rank == playingRank + 1) {
PreloadTarget(
preload = true,
startPositionMs = startPositionMs.coerceAtLeast(0L),
durationMs = rangeMs.coerceAtLeast(0L),
)
} else {
PreloadTarget(preload = false)
}
/**
* The ranks whose preloaded work is now dead and should be handed back.
*
* Strictly *behind* the playing rank, never the playing rank itself. That exclusion is the
* whole safety of eviction: on an advance the episode starting is the one the manager was
* preloading, and the player has been handed its `MediaSource` removing it from the manager
* releases that source underneath a decoder that is using it. Everything before it is finished
* with and can go.
*/
internal fun obsoletePreloadRanks(ranks: Collection<Int>, playingRank: Int): List<Int> =
ranks.filter { it < playingRank }.sorted()
/**
* How much of the next episode is pulled into memory ahead of time.
*
* Deliberately small. The expensive part of starting a stream here is not the bytes it is
* opening the connection to Emby, reading the container header and then the seek index, which
* on a Matroska file commonly sits at the *end* of it (see [PlayerEngine]). Preparing the
* source and selecting its tracks pays all of that, and a specified range is what makes Media3
* go that far; the seconds of media on top are the margin that lets the first frame decode
* without a round trip. Five of them is comfortably more than [PlayerEngine]'s
* `BUFFER_FOR_PLAYBACK_MS` needs to start.
*
* The number is a memory ceiling before it is anything else: an episode direct-playing at 4K
* can run past 30 Mbps, so every second held ahead is a few megabytes on a box that has little
* to spare, for a title the viewer may well not go on to.
*/
internal const val PRELOAD_RANGE_MS = 5_000L
@@ -0,0 +1,84 @@
package com.ponzischeme89.memby.data.analytics
import org.junit.Assert.assertEquals
import org.junit.Test
class PlaybackEntryPointTest {
/**
* The kind is asked first because it is the app's own classification and is the same on
* both paths. Every one of these is Continue Watching as far as a viewer is concerned:
* the gateway calls the row `continue`, a home cache written by an older build still
* carries `nextup`, and the direct path composes its own.
*/
@Test
fun `every shape of the Continue Watching row is one entry point`() {
val expected = PlaybackEntryPoint.CONTINUE_WATCHING
assertEquals(expected, playbackEntryPointFor("continue", "CONTINUE"))
assertEquals(expected, playbackEntryPointFor("continue", null))
assertEquals(expected, playbackEntryPointFor("nextup", null))
assertEquals(expected, playbackEntryPointFor("continue-watching", null))
// A row id this build has never seen, with the kind still saying what it is.
assertEquals(expected, playbackEntryPointFor("home-row-482", "continue"))
}
@Test
fun `the panes that are not rows name themselves`() {
assertEquals(PlaybackEntryPoint.HOME_HERO, playbackEntryPointFor("home-movie-hero"))
assertEquals(PlaybackEntryPoint.SEARCH, playbackEntryPointFor("search-results"))
assertEquals(
PlaybackEntryPoint.GENRE_BROWSER,
playbackEntryPointFor("genre-browser-results"),
)
}
/**
* Recommendation shelves generate their ids a seed id, a time budget, a taste cluster
* so matching them one at a time would be a losing game and every household would end up
* with a long tail of entry points used once each.
*/
@Test
fun `generated recommendation rows collapse to one entry point`() {
val expected = PlaybackEntryPoint.RECOMMENDATION
assertEquals(expected, playbackEntryPointFor("for-you:pick-up"))
assertEquals(expected, playbackEntryPointFor("because-you-watched:8821"))
assertEquals(expected, playbackEntryPointFor("highly-engaged"))
}
@Test
fun `a favourites row is a favourites row under either spelling`() {
assertEquals(PlaybackEntryPoint.FAVOURITES, playbackEntryPointFor("favorites"))
assertEquals(PlaybackEntryPoint.FAVOURITES, playbackEntryPointFor("saved-row", "FAVORITES"))
}
/**
* Nothing to go on is the detail page, not unknown: a Play press with no row behind it
* came from a page the viewer reached some other way the "More like this" trail, a
* schedule card's series page, a page restored after the player returned.
*/
@Test
fun `a press with no row behind it is the detail page`() {
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, playbackEntryPointFor(null, null))
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, playbackEntryPointFor(" ", null))
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, playbackEntryPointFor("some-other-row"))
}
/**
* The ids are the wire values, so they are pinned: renaming one would silently split a
* household's history in two, with the console reporting the old name as a feature that
* stopped being used on the day of the release.
*/
@Test
fun `the wire values are stable and round-trip`() {
assertEquals("continue_watching", PlaybackEntryPoint.CONTINUE_WATCHING.id)
assertEquals("magic_movie", PlaybackEntryPoint.MAGIC_MOVIE.id)
assertEquals("next_episode", PlaybackEntryPoint.NEXT_EPISODE.id)
for (entry in PlaybackEntryPoint.entries) {
assertEquals(entry, PlaybackEntryPoint.fromId(entry.id))
}
// A television talking to a build that knows an entry point this one does not still
// records the playback, rather than dropping it for want of a name.
assertEquals(PlaybackEntryPoint.UNKNOWN, PlaybackEntryPoint.fromId("party_mode"))
assertEquals(PlaybackEntryPoint.UNKNOWN, PlaybackEntryPoint.fromId(null))
}
}
@@ -0,0 +1,201 @@
package com.ponzischeme89.memby.data.analytics
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The two entry points that recorded nothing, and the one that always did.
*
* Continue Watching and Magic produced journeys that read as somebody arriving in the player
* from nowhere Magic because the press happens inside the player, which had no way to reach
* the collector at all, and Continue Watching because the only thing naming where a playback
* came from was the launcher's raw row id, which is the gateway's and is not a grouping
* anything downstream could use. These pin the sequence each one now records; the detail-page
* case is here because it is the path that already worked and must keep working.
*/
class PlaybackJourneyTest {
private fun collector() = JourneyAnalytics(
userId = "user-1",
now = { 1_700_000_000_000L },
journeyId = "journey-1",
).also { it.drain() } // Discard the home_open the journey opens with.
private fun List<GatewayJourneyEvent>.step(category: String, action: String) =
singleOrNull { it.category == category && it.action == action }
?: error("expected exactly one $category/$action in ${map { it.category + "/" + it.action }}")
@Test
fun `a resume from Continue Watching records the whole sequence`() {
val journey = collector()
val entryPoint = playbackEntryPointFor(rowId = "continue", rowKind = "CONTINUE")
PlaybackJourney.requested(
sink = journey, entryPoint = entryPoint, screen = "home",
itemId = "ep-9", itemName = "The Pitt 7:00 A.M.", itemType = "Episode",
)
PlaybackJourney.started(
sink = journey, entryPoint = entryPoint,
itemId = "ep-9", itemName = "The Pitt 7:00 A.M.", itemType = "Episode",
playSessionId = "sess-9",
)
val events = journey.drain()
assertEquals(PlaybackEntryPoint.CONTINUE_WATCHING, entryPoint)
val requested = events.step("playback", "request")
assertEquals("continue_watching", requested.source)
assertEquals("player", requested.target)
assertEquals("ep-9", requested.itemId)
assertEquals("Episode", requested.itemType)
assertEquals("The Pitt 7:00 A.M.", requested.itemName)
val started = events.step("playback", "start")
assertEquals("continue_watching", started.source)
assertEquals("success", started.outcome)
assertEquals("sess-9", started.playSessionId)
assertEquals("user-1", started.userId)
assertEquals("2023-11-14T22:13:20Z", started.occurredAt)
// Ordered, and inside the one journey the app session opened with.
assertEquals(events.map { it.sequence }.sorted(), events.map { it.sequence })
assertTrue(events.all { it.journeyId == "journey-1" })
}
@Test
fun `a Magic pick records the recommendation and the playback it causes`() {
val journey = collector()
// What the player does around the press, in order.
PlaybackJourney.magicRequested(journey)
PlaybackJourney.magicSelected(
sink = journey, itemId = "film-3", itemName = "Boy", itemType = "Movie",
)
PlaybackJourney.finished(
sink = journey, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING,
itemId = "film-1", itemName = "Whale Rider", itemType = "Movie",
playSessionId = "sess-1", completed = false,
)
PlaybackJourney.requested(
sink = journey, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE, screen = "player",
itemId = "film-3", itemName = "Boy", itemType = "Movie",
)
PlaybackJourney.started(
sink = journey, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE,
itemId = "film-3", itemName = "Boy", itemType = "Movie", playSessionId = "sess-3",
)
val events = journey.drain()
assertEquals("magic_movie", events.step("recommendations", "request").feature)
val selected = events.step("recommendations", "select")
assertEquals("Boy", selected.itemName)
assertEquals("success", selected.outcome)
// The film that was on ends, so a chain of Magic presses does not leave every title
// but the last one without an ending.
val finished = events.step("playback", "complete")
assertEquals("film-1", finished.itemId)
assertEquals("abandoned", finished.outcome)
val requested = events.step("playback", "request")
assertEquals("magic_movie", requested.source)
assertEquals("film-3", requested.itemId)
assertEquals("sess-3", events.step("playback", "start").playSessionId)
// One app session, not two: the console cuts a second *viewing* journey at the
// playback request, and it can only do that if both films are in one journey id.
assertEquals(setOf("journey-1"), events.map { it.journeyId }.toSet())
}
@Test
fun `playing from a detail page still records a request and a start`() {
val journey = collector()
val entryPoint = playbackEntryPointFor(rowId = null, rowKind = null)
PlaybackJourney.requested(
sink = journey, entryPoint = entryPoint, screen = "movies",
itemId = "film-7", itemName = "Hunt for the Wilderpeople", itemType = "Movie",
)
PlaybackJourney.started(
sink = journey, entryPoint = entryPoint, itemId = "film-7",
itemName = "Hunt for the Wilderpeople", itemType = "Movie", playSessionId = "sess-7",
)
val events = journey.drain()
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, entryPoint)
assertEquals("detail_page", events.step("playback", "request").source)
assertEquals("movies", events.step("playback", "request").screen)
assertEquals("success", events.step("playback", "start").outcome)
}
@Test
fun `a failure is the same step wearing the other outcome`() {
val journey = collector()
PlaybackJourney.failed(
sink = journey, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING, screen = "player",
itemId = "ep-9", itemName = "The Pitt", itemType = "Episode",
)
val started = journey.drain().step("playback", "start")
assertEquals("failure", started.outcome)
assertEquals("continue_watching", started.source)
}
/**
* A play session id is Emby's string, and the gateway drops a whole event whose fields it
* cannot read. Losing the identifier is a smaller loss than losing the step, so it is
* cleaned on the way in rather than sent as it came.
*/
@Test
fun `an unreadable play session id costs the identifier and not the step`() {
val journey = collector()
PlaybackJourney.started(
sink = journey, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE, itemId = "film-3",
itemName = "Boy", itemType = "Movie", playSessionId = "session for Boy (2010)",
)
val started = journey.drain().step("playback", "start")
assertEquals("sessionforBoy2010", started.playSessionId)
assertEquals("success", started.outcome)
}
@Test
fun `the player writes into the journey the launcher opened`() {
val launcher = JourneyTracker.begin("user-1")
launcher.drain()
// The launcher's own step, then one written from the player, which holds no reference
// to the collector and reaches it through the process-wide tracker.
PlaybackJourney.requested(
sink = launcher, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING, screen = "home",
itemId = "ep-9", itemName = "The Pitt", itemType = "Episode",
)
PlaybackJourney.started(
sink = JourneyTracker, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING,
itemId = "ep-9", itemName = "The Pitt", itemType = "Episode", playSessionId = "sess-9",
)
val events = launcher.drain()
assertEquals(2, events.size)
assertEquals(1, events.map { it.journeyId }.distinct().size)
assertEquals("sess-9", events.step("playback", "start").playSessionId)
}
@Test
fun `a profile switch starts a journey of its own`() {
val first = JourneyTracker.begin("user-1")
val firstJourneyId = first.drain().first().journeyId
val second = JourneyTracker.begin("user-2")
PlaybackJourney.started(
sink = JourneyTracker, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE, itemId = "film-3",
itemName = "Boy", itemType = "Movie", playSessionId = "sess-3",
)
// The person who signed out keeps nothing of what the next one did.
assertTrue(first.drain().isEmpty())
val events = second.drain()
assertEquals("user-2", events.step("playback", "start").userId)
assertNotEquals(firstJourneyId, events.first().journeyId)
}
}
@@ -0,0 +1,74 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the two rules that decide what next-episode preloading costs.
*
* Neither can be checked on a device without watching an episode end, and both fail quietly:
* preloading one episode too many is invisible except as memory on a box that had none to
* spare, and evicting one episode too few is invisible except as a connection to Emby held
* open for something nobody is going to watch.
*/
class PreloadPlanTest {
@Test
fun `only the episode immediately after the one playing is preloaded`() {
assertTrue(preloadTargetFor(rank = 1, playingRank = 0).preload)
assertTrue(preloadTargetFor(rank = 5, playingRank = 4).preload)
}
@Test
fun `the episode playing is not preloaded`() {
// It is registered — it is what the player was handed — but there is nothing left to
// load ahead for it, and a range target here would have Media3 open a second reader.
assertFalse(preloadTargetFor(rank = 3, playingRank = 3).preload)
}
@Test
fun `nothing two or more episodes ahead is preloaded`() {
assertFalse(preloadTargetFor(rank = 2, playingRank = 0).preload)
assertFalse(preloadTargetFor(rank = 9, playingRank = 0).preload)
}
@Test
fun `nothing behind the episode playing is preloaded`() {
assertFalse(preloadTargetFor(rank = 0, playingRank = 1).preload)
assertFalse(preloadTargetFor(rank = 2, playingRank = 7).preload)
}
@Test
fun `the preloaded range is bounded and starts where the episode would resume`() {
val target = preloadTargetFor(rank = 1, playingRank = 0, startPositionMs = 42_000L)
assertEquals(42_000L, target.startPositionMs)
assertEquals(PRELOAD_RANGE_MS, target.durationMs)
}
@Test
fun `a negative resume position is read as the start of the episode`() {
// Nothing should ever send one, but a negative start would be handed to Media3 as a
// seek before the beginning of the file.
assertEquals(0L, preloadTargetFor(rank = 1, playingRank = 0, startPositionMs = -5L).startPositionMs)
}
@Test
fun `eviction never includes the episode playing`() {
// The load-bearing one. On an advance the player has just been handed the source for
// the episode at playingRank; removing it from the manager releases that source out
// from under a decoder that is using it.
assertEquals(listOf(0, 1), obsoletePreloadRanks(listOf(0, 1, 2, 3), playingRank = 2))
}
@Test
fun `eviction leaves the episode ahead alone`() {
assertEquals(emptyList<Int>(), obsoletePreloadRanks(listOf(2, 3), playingRank = 2))
}
@Test
fun `eviction of an empty journey is empty`() {
assertEquals(emptyList<Int>(), obsoletePreloadRanks(emptyList(), playingRank = 0))
}
}
+1
View File
@@ -288,6 +288,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
// of what the gateway does in the background rather than as more wiring in here.
server.RegisterHousekeeping(sched)
server.RegisterCreditsTasks(sched)
server.RegisterWatchTimeTasks(sched)
sched.Start(ctx)
// Installed after the server exists, because both halves of a finished import are
+15 -1
View File
@@ -51,6 +51,10 @@ type adminMembyAccount struct {
// themeAllowed take. The console renders that as every box ticked, which is what an
// operator who has never touched the page should see.
Themes []string `json:"themes"`
// WatchTime is what Tracearr says this person has watched. It is zero-valued and
// unmatched for a household running no Tracearr, which the console reads as "not
// available" rather than as "watched nothing".
WatchTime watchTimeSummary `json:"watchTime"`
}
type adminAccountSettings struct {
@@ -108,6 +112,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
notifications = map[string]store.NotificationPreferences{}
}
// One grouped query for the whole household rather than one per person: this page grows
// with the family, and a per-account read is how a directory becomes slow.
identifiers := make([]watchTimeAccount, 0, len(accounts))
for _, account := range accounts {
identifiers = append(identifiers, watchTimeAccount{ID: account.ID, Username: account.Username})
}
watchTime := s.watchTimeForAccounts(r.Context(), identifiers)
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
pref := preferences[account.ID]
@@ -137,8 +149,10 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
if !savedNotifications {
notificationPrefs = store.DefaultNotificationPreferences()
}
watched, matchedWatchTime := watchTime[account.ID]
result = append(result, adminMembyAccount{
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
WatchTime: summariseWatchTime(watched, matchedWatchTime),
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Themes: nonNilStrings(themes[account.ID]),
+12 -5
View File
@@ -43,10 +43,15 @@ type journeyEventPayload struct {
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId"`
ItemName string `json:"itemName"`
ItemType string `json:"itemType"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
// The Emby play session a playback step belongs to. Validated like every other
// controlled field: it is Emby's string rather than ours, and an event carrying one this
// cannot read is dropped whole, so the television sanitises it before sending.
PlaySessionID string `json:"playSessionId"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
}
type journeyAnalyticsRequest struct {
@@ -100,7 +105,8 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
!journeyOutcomes[payload.Outcome] {
return store.JourneyEvent{}, false
}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemType}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target,
payload.ItemID, payload.ItemType, payload.PlaySessionID}
for _, field := range fields {
if !safeAnalyticsValue(field, 100) {
return store.JourneyEvent{}, false
@@ -114,8 +120,9 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
Target: payload.Target, ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
Outcome: payload.Outcome}, true
Target: payload.Target, ItemID: payload.ItemID,
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome}, true
}
func safeAnalyticsValue(value string, max int) bool {
+53
View File
@@ -37,6 +37,59 @@ func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) {
}
}
// The entry points the television states for a playback, and the two identifiers a playback
// step carries. Every one of these words has to survive the vocabulary check, or the step is
// dropped on arrival with nothing said and the journey reads as a viewer who reached the
// player from nowhere — which is precisely what Continue Watching and Magic used to look
// like, for want of the steps being recorded at all.
func TestJourneyEventAcceptsPlaybackEntryPoints(t *testing.T) {
now := time.Now().UTC()
for _, source := range []string{
"continue_watching", "magic_movie", "next_episode", "home_hero", "search",
"genre_browser", "calendar", "favourites", "recommendation", "detail_page",
"screensaver", "unknown",
} {
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Sequence: 1,
Category: "playback", Action: "request", Screen: "home", Feature: "playback",
Source: source, Target: "player", ItemID: "8821", ItemName: "Boy", ItemType: "Movie"}
event, ok := toJourneyEvent(payload, "u1", now)
if !ok {
t.Fatalf("entry point %q was rejected", source)
}
if event.Source != source || event.ItemID != "8821" {
t.Fatalf("entry point %q: unexpected event %+v", source, event)
}
}
}
func TestJourneyEventRetainsThePlaySession(t *testing.T) {
now := time.Now().UTC()
for _, outcome := range []string{"success", "failure", "completed", "abandoned"} {
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
Action: "start", Screen: "player", Feature: "playback", Source: "magic_movie",
Target: "player", ItemID: "42", ItemName: "Whale Rider", ItemType: "Movie",
PlaySessionID: "b3d1f0a4-9c22-4f61-8a10-77d0e2c9aa51", Outcome: outcome}
event, ok := toJourneyEvent(payload, "u1", now)
if !ok {
t.Fatalf("playback outcome %q was rejected", outcome)
}
if event.PlaySessionID != payload.PlaySessionID || event.Outcome != outcome {
t.Fatalf("outcome %q: unexpected event %+v", outcome, event)
}
}
}
// A play session id is Emby's string rather than the gateway's vocabulary, so it is checked
// like everything else: an unreadable one must not be written into the column.
func TestJourneyEventRejectsAnUnreadablePlaySession(t *testing.T) {
now := time.Now().UTC()
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
Action: "start", PlaySessionID: "session for Boy (2010)"}
if _, ok := toJourneyEvent(payload, "u1", now); ok {
t.Fatal("a play session id containing free text was accepted")
}
}
func TestJourneyEventRetainsTheItemName(t *testing.T) {
now := time.Now().UTC()
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
+13
View File
@@ -26,6 +26,7 @@ const (
featureSeasonalDecorations = "seasonal_decorations"
featureGenreBrowser = "genre_browser"
featureTVCalendar = "tv_calendar"
featureWatchTimeDigest = "watch_time_digest"
)
type featureDefinition struct {
@@ -133,6 +134,18 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
},
{
// No capability, because nothing on the television has to understand this: the
// summary is an ordinary entry in My Alerts, which every build that has that page
// already renders. This switch is the household's — a viewer's own is the
// watch-time toggle on their account.
Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications",
Description: "Tell each viewer how long they watched this week and this month, on " +
"Sunday evening, with a summary of the month just gone once it ends. Read from " +
"Tracearr; a household running none never sends one.",
DefaultEnabled: true, MinimumProtocol: 1,
Recovery: "Server-enforced; takes effect before the next summary is due.",
},
{
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
@@ -84,17 +84,25 @@ func filterStoredNotifications(notifications []store.UserNotification, prefs sto
if !prefs.Enabled {
return []store.UserNotification{}
}
if prefs.SonarrAlerts {
if prefs.SonarrAlerts && prefs.WatchTimeDigest {
return notifications
}
filtered := make([]store.UserNotification, 0, len(notifications))
for _, notification := range notifications {
switch notification.Kind {
case "show-return", "show-added", "show-cancelled", "auto-follow":
continue
default:
filtered = append(filtered, notification)
if !prefs.SonarrAlerts {
continue
}
// A summary already sitting in somebody's list is withdrawn the moment they turn
// these off, rather than waiting to be dismissed one at a time: switching a weekly
// notice off is a statement about the ones already there as much as the next one.
case watchTimeWeeklyKind, watchTimeMonthlyKind:
if !prefs.WatchTimeDigest {
continue
}
}
filtered = append(filtered, notification)
}
return filtered
}
+296
View File
@@ -0,0 +1,296 @@
package api
import (
"context"
"fmt"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Watch time is Tracearr's answer, attributed to Emby accounts and read two ways: as figures
// beside a person in the console, and as the weekly summary the television shows them.
//
// Everything about *when* a week begins and *how* a span is worded is in this file as pure
// functions, because both readers have to agree — a console saying eleven hours and a digest
// saying "10h 58m" for the same window is the sort of disagreement that makes an operator
// stop trusting both numbers.
const (
// digestWeekday and digestHour are when the weekly summary goes out, in the household's
// own time. Sunday evening rather than Monday morning because the figure sent is
// week-to-date: on a Monday it would be a summary of almost nothing, and by Sunday
// evening it is the week the viewer actually had.
digestWeekday = time.Sunday
digestHour = 20
// watchTimeDigestFloor is the least viewing worth telling somebody about. A digest
// reporting four minutes is a notification about a title somebody started and abandoned,
// and a feed that reports those is one nobody opens.
watchTimeDigestFloor = 20 * time.Minute
)
// weekStartIn is the local Monday at midnight on or before now.
//
// Monday rather than Sunday because that is the week New Zealand keeps, and the boundary is
// computed by *date* rather than by subtracting hours: a week containing a daylight-saving
// change is 23 or 25 hours short or long, and now.Add(-7*24*time.Hour) would put the boundary
// an hour inside the previous Sunday twice a year.
func weekStartIn(now time.Time, location *time.Location) time.Time {
local := now.In(location)
offset := (int(local.Weekday()) + 6) % 7 // Monday becomes 0
day := local.AddDate(0, 0, -offset)
return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location)
}
// monthStartIn is local midnight on the first of the month containing now.
func monthStartIn(now time.Time, location *time.Location) time.Time {
local := now.In(location)
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location)
}
// previousMonth is the closed window of the calendar month before the one containing now, and
// the YYYY-MM key that names it. Half-open on the right, so a session that started on the
// stroke of midnight belongs to exactly one of the two months.
func previousMonth(now time.Time, location *time.Location) (from, to time.Time, key string) {
to = monthStartIn(now, location)
from = to.AddDate(0, -1, 0)
return from, to, from.Format("2006-01")
}
// weekKey names a week the way the source key needs it — a stable string that changes exactly
// once per week. ISO year-and-week, so the last days of December cannot collide with the
// first days of January.
func weekKey(now time.Time, location *time.Location) string {
year, week := weekStartIn(now, location).ISOWeek()
return fmt.Sprintf("%04d-W%02d", year, week)
}
// weeklyDigestDue is the whole schedule rule, kept pure so the send window is testable
// without waiting a week for one.
//
// The task runs hourly and this says yes for every run from the appointed hour to the end of
// that day, rather than only on the hour itself: a container restarted at eight on a Sunday
// evening, or a run that failed, must still deliver the summary. Sending it twice is prevented
// by the notification's source key rather than by narrowing this window, which is the same
// trade the *arr lifecycle scanner makes — an idempotent write is a better guard than a timer.
func weeklyDigestDue(now time.Time, location *time.Location) bool {
local := now.In(location)
return local.Weekday() == digestWeekday && local.Hour() >= digestHour
}
// monthlyDigestDue says whether the *previous* month may be summarised yet. Any run at or
// after the appointed hour qualifies, so a gateway that was switched off over the turn of the
// month still delivers the summary when it comes back rather than skipping it for ever.
func monthlyDigestDue(now time.Time, location *time.Location) bool {
return now.In(location).Hour() >= digestHour
}
// formatWatchDuration is how a span of viewing is worded everywhere Memby says one out loud.
//
// Rounded to the minute, because a viewing figure carrying seconds invites somebody to
// reconcile it against something, and nothing it is derived from is accurate to the second.
func formatWatchDuration(d time.Duration) string {
minutes := int(d.Round(time.Minute) / time.Minute)
if minutes <= 0 {
return "no time"
}
hours := minutes / 60
minutes %= 60
switch {
case hours == 0:
return fmt.Sprintf("%d min", minutes)
case minutes == 0 && hours == 1:
return "1 hour"
case minutes == 0:
return fmt.Sprintf("%d hours", hours)
default:
return fmt.Sprintf("%dh %dm", hours, minutes)
}
}
// weeklyDigestMessage is the sentence a viewer reads. Both figures are in it because the
// question "have I watched a lot this week" is only answerable beside the month it sits in —
// and the month is dropped when the two are the same span, which is what the first week of a
// month looks like, rather than printing the same number twice.
func weeklyDigestMessage(week, month time.Duration, topTitle string) string {
var b strings.Builder
b.WriteString("You watched " + formatWatchDuration(week) + " this week")
if month > week {
b.WriteString(", and " + formatWatchDuration(month) + " so far this month")
}
b.WriteString(".")
if title := strings.TrimSpace(topTitle); title != "" {
b.WriteString(" Mostly " + title + ".")
}
return b.String()
}
// monthlyDigestMessage summarises a month that has ended. It names the month, because unlike
// the weekly note this can arrive days after the window it describes closed.
func monthlyDigestMessage(month time.Duration, monthName, topTitle string) string {
var b strings.Builder
b.WriteString("You watched " + formatWatchDuration(month) + " in " + monthName + ".")
if title := strings.TrimSpace(topTitle); title != "" {
b.WriteString(" Most of it on " + title + ".")
}
return b.String()
}
// watchTimeSummary is one account's figures as the console reads them. Matched is carried
// because "nobody by that name in Tracearr" and "somebody who has watched nothing" are
// different facts, and a console that showed both as a row of zeroes would leave an operator
// investigating a viewer rather than an integration.
type watchTimeSummary struct {
Matched bool `json:"matched"`
Username string `json:"tracearrUsername,omitempty"`
WeekMs int64 `json:"weekMs"`
MonthMs int64 `json:"monthMs"`
TotalMs int64 `json:"totalMs"`
WeekSessions int `json:"weekSessions"`
MonthSessions int `json:"monthSessions"`
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
}
// tracearrEnabled is whether there is anything to read at all. Every watch-time reader checks
// it first, so a household running no Tracearr pays no query for a feature it cannot have.
func (s *Server) tracearrEnabled() bool {
return strings.TrimSpace(s.cfg.TracearrURL) != "" && strings.TrimSpace(s.cfg.TracearrAPIKey) != ""
}
// attributeWatchTime maps Tracearr identities onto Emby user ids.
//
// The username is the join, because it is the identity the two systems genuinely share and
// the only one present for a viewer the recommendation builder has never profiled. The
// recorded identity map wins where it exists: it is what the builder actually matched, so a
// household that has since renamed somebody in one system keeps their figures instead of
// silently reporting zero.
//
// It is a pure function over both readings so the matching rule can be tested without a
// database, and so the console and the digest cannot attribute the same rows differently.
func attributeWatchTime(
accounts []watchTimeAccount,
totals []store.WatchTimeTotals,
identities map[string]store.RecommendationIdentity,
) map[string]store.WatchTimeTotals {
byName := make(map[string]store.WatchTimeTotals, len(totals))
byID := make(map[string]store.WatchTimeTotals, len(totals))
for _, entry := range totals {
if key := strings.ToLower(strings.TrimSpace(entry.Username)); key != "" {
byName[key] = entry
}
if entry.TracearrUserID != "" {
byID[entry.TracearrUserID] = entry
}
}
out := make(map[string]store.WatchTimeTotals, len(accounts))
for _, account := range accounts {
identity := identities[account.ID]
if identity.TracearrUserID != "" {
if entry, ok := byID[identity.TracearrUserID]; ok {
out[account.ID] = entry
continue
}
}
for _, candidate := range []string{identity.Username, account.Username} {
key := strings.ToLower(strings.TrimSpace(candidate))
if key == "" {
continue
}
if entry, ok := byName[key]; ok {
out[account.ID] = entry
break
}
}
}
return out
}
// watchTimeAccount is the little of an account the attribution rule needs. Declared here
// rather than taking store.MembyAccount so the rule can be tested with two fields and so the
// digest, which works from sessions rather than from accounts, can use it too.
type watchTimeAccount struct {
ID string
Username string
}
// watchTimeForAccounts is the console's reading: one grouped query, attributed, and never
// fatal. A Tracearr table that will not read must not cost the operator the account list.
func (s *Server) watchTimeForAccounts(
ctx context.Context,
accounts []watchTimeAccount,
) map[string]store.WatchTimeTotals {
if !s.tracearrEnabled() || len(accounts) == 0 {
return nil
}
location := s.householdLocation()
now := time.Now()
totals, err := s.store.TracearrWatchTime(ctx, weekStartIn(now, location), monthStartIn(now, location))
if err != nil {
s.loggerFor(ctx).Warn("watch time read failed", "error", err)
return nil
}
identities, err := s.store.TracearrIdentities(ctx)
if err != nil {
// The username join still works without it; only a renamed viewer loses their
// figures, which is better than the whole page losing them.
s.loggerFor(ctx).Warn("Tracearr identity map unavailable", "error", err)
identities = map[string]store.RecommendationIdentity{}
}
return attributeWatchTime(accounts, totals, identities)
}
// summariseWatchTime turns the store's row into what the console reads, including the case
// where there is no row.
func summariseWatchTime(totals store.WatchTimeTotals, matched bool) watchTimeSummary {
if !matched {
return watchTimeSummary{}
}
return watchTimeSummary{
Matched: true, Username: totals.Username,
WeekMs: totals.WeekMs, MonthMs: totals.MonthMs, TotalMs: totals.TotalMs,
WeekSessions: totals.WeekSessions, MonthSessions: totals.MonthSessions,
LastWatchedAt: totals.LastWatchedAt,
}
}
// indexWatchTimeRanges keys a window's rows for lookup by either identity. Both maps are
// built because the digest resolves the same two ways attributeWatchTime does — by the id the
// recommendation builder recorded where there is one, and by name otherwise.
func indexWatchTimeRanges(windows []store.WatchTimeRange) (byID, byName map[string]store.WatchTimeRange) {
byID = make(map[string]store.WatchTimeRange, len(windows))
byName = make(map[string]store.WatchTimeRange, len(windows))
for _, window := range windows {
if window.TracearrUserID != "" {
byID[window.TracearrUserID] = window
}
if key := strings.ToLower(strings.TrimSpace(window.Username)); key != "" {
byName[key] = window
}
}
return byID, byName
}
// lookupWatchTimeRange finds one person's row in an indexed window.
func lookupWatchTimeRange(
byID, byName map[string]store.WatchTimeRange,
identity store.RecommendationIdentity,
username string,
) store.WatchTimeRange {
if identity.TracearrUserID != "" {
if window, ok := byID[identity.TracearrUserID]; ok {
return window
}
}
for _, candidate := range []string{identity.Username, username} {
key := strings.ToLower(strings.TrimSpace(candidate))
if key == "" {
continue
}
if window, ok := byName[key]; ok {
return window
}
}
return store.WatchTimeRange{}
}
+217
View File
@@ -0,0 +1,217 @@
package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The weekly viewing summary, delivered as a personal notification rather than as a service
// alert: a service alert is the house being told something, and how long somebody watched is
// nobody else's news. It therefore lands in My Alerts, follows the person to whichever
// television they sign into, and is dismissed the way every other notification there is.
//
// Two things make this safe to run hourly. The *source key* is the only thing preventing a
// repeat — `watch-time:weekly:2026-W33` is written ON CONFLICT DO NOTHING, so a container
// restarted three times on a Sunday evening delivers one summary, and a gateway that was
// switched off all evening still delivers it the next hour it is up. And every window it
// reports is computed from a household-local calendar rather than by subtracting hours, so a
// daylight-saving change cannot move a week boundary underneath it.
const (
watchTimeWeeklyKind = "watch-time-week"
watchTimeMonthlyKind = "watch-time-month"
)
// RegisterWatchTimeTasks declares the digest. Registered beside the housekeeping jobs so the
// console lists it, an operator can run it by hand, and its last run is visible — which for a
// job that fires once a week is the difference between "it has not sent anything" and "it has
// not run".
func (s *Server) RegisterWatchTimeTasks(sched *scheduler.Scheduler) {
if sched == nil {
return
}
sched.Register(scheduler.Task{
ID: "watch-time-digest",
Name: "Weekly watch-time summary",
Group: "Notifications",
Description: fmt.Sprintf(
"Sends each viewer their week-to-date and month-to-date viewing on %s evening, "+
"and a summary of the month just gone once it ends. Needs Tracearr.",
digestWeekday),
// Hourly rather than daily: the send window is an evening in the household's own
// time, and a daily task would have to be lucky to land inside it.
Interval: time.Hour,
Timeout: 5 * time.Minute,
Run: s.runWatchTimeDigest,
})
}
func (s *Server) runWatchTimeDigest(ctx context.Context) (string, error) {
// Three refusals before any work, in the order that costs least. A household with no
// Tracearr has nothing to report; an operator who has switched the feature off has said
// so for the whole house; and outside the send window there is nothing due.
if !s.tracearrEnabled() {
return "", nil
}
if !s.featureEnabled(ctx, featureWatchTimeDigest) {
return "", nil
}
location := s.householdLocation()
now := time.Now()
weekly := weeklyDigestDue(now, location)
monthly := monthlyDigestDue(now, location)
if !weekly && !monthly {
return "", nil
}
accounts, err := s.store.KnownUsers(ctx)
if err != nil {
return "", fmt.Errorf("watch-time digest: read viewers: %w", err)
}
if len(accounts) == 0 {
return "", nil
}
identities, err := s.store.TracearrIdentities(ctx)
if err != nil {
// Same trade the console makes: the username join still works, so only a viewer
// renamed in one system loses their summary rather than everybody losing theirs.
s.log.Warn("Tracearr identity map unavailable for watch-time digest", "error", err)
identities = map[string]store.RecommendationIdentity{}
}
sent := 0
if weekly {
count, err := s.sendWeeklyWatchTime(ctx, now, location, accounts, identities)
if err != nil {
return "", err
}
sent += count
}
if monthly {
count, err := s.sendMonthlyWatchTime(ctx, now, location, accounts, identities)
if err != nil {
return "", err
}
sent += count
}
// An empty detail keeps a job that runs every hour out of the operator's notification
// feed every hour; see scheduler.announce.
if sent == 0 {
return "", nil
}
if sent == 1 {
return "1 viewing summary sent", nil
}
return fmt.Sprintf("%d viewing summaries sent", sent), nil
}
func (s *Server) sendWeeklyWatchTime(
ctx context.Context,
now time.Time,
location *time.Location,
accounts []store.KnownUser,
identities map[string]store.RecommendationIdentity,
) (int, error) {
weekStart := weekStartIn(now, location)
week, err := s.store.TracearrWatchTimeRange(ctx, weekStart, now)
if err != nil {
return 0, fmt.Errorf("watch-time digest: week: %w", err)
}
month, err := s.store.TracearrWatchTimeRange(ctx, monthStartIn(now, location), now)
if err != nil {
return 0, fmt.Errorf("watch-time digest: month: %w", err)
}
weekByID, weekByName := indexWatchTimeRanges(week)
monthByID, monthByName := indexWatchTimeRanges(month)
key := "watch-time:weekly:" + weekKey(now, location)
eventAt := now
sent := 0
for _, account := range accounts {
identity := identities[account.ID]
watched := lookupWatchTimeRange(weekByID, weekByName, identity, account.Username)
total := time.Duration(watched.Ms) * time.Millisecond
if total < watchTimeDigestFloor {
continue
}
if !s.watchTimeDigestWanted(ctx, account.ID) {
continue
}
monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username)
message := weeklyDigestMessage(
total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle)
if err := s.store.UpsertNotification(
ctx, account.ID, key, watchTimeWeeklyKind, "", "Your week in Memby", message, &eventAt,
); err != nil {
s.log.Warn("weekly watch-time summary failed", "user", account.ID, "error", err)
continue
}
sent++
}
if sent > 0 {
s.log.Info("weekly watch-time summaries sent", "viewers", sent, "week", weekKey(now, location))
}
return sent, nil
}
func (s *Server) sendMonthlyWatchTime(
ctx context.Context,
now time.Time,
location *time.Location,
accounts []store.KnownUser,
identities map[string]store.RecommendationIdentity,
) (int, error) {
from, to, monthID := previousMonth(now, location)
windows, err := s.store.TracearrWatchTimeRange(ctx, from, to)
if err != nil {
return 0, fmt.Errorf("watch-time digest: previous month: %w", err)
}
byID, byName := indexWatchTimeRanges(windows)
key := "watch-time:monthly:" + monthID
monthName := from.Format("January")
eventAt := to
sent := 0
for _, account := range accounts {
identity := identities[account.ID]
watched := lookupWatchTimeRange(byID, byName, identity, account.Username)
total := time.Duration(watched.Ms) * time.Millisecond
if total < watchTimeDigestFloor {
continue
}
if !s.watchTimeDigestWanted(ctx, account.ID) {
continue
}
message := monthlyDigestMessage(total, monthName, watched.TopTitle)
if err := s.store.UpsertNotification(
ctx, account.ID, key, watchTimeMonthlyKind,
"", monthName+" in Memby", message, &eventAt,
); err != nil {
s.log.Warn("monthly watch-time summary failed", "user", account.ID, "error", err)
continue
}
sent++
}
if sent > 0 {
s.log.Info("monthly watch-time summaries sent", "viewers", sent, "month", monthID)
}
return sent, nil
}
// watchTimeDigestWanted reads the viewer's own switches. A preference that will not load is
// read as "not now" rather than as consent: this is a notification about somebody's own
// habits, and sending one to a person who may have declined it is the worse of the two
// mistakes.
func (s *Server) watchTimeDigestWanted(ctx context.Context, userID string) bool {
prefs, err := s.notificationPreferencesFor(ctx, userID)
if err != nil {
s.log.Warn("notification preferences unavailable for watch-time digest",
"user", userID, "error", err)
return false
}
return prefs.Enabled && prefs.WatchTimeDigest
}
+252
View File
@@ -0,0 +1,252 @@
package api
import (
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Auckland is the household this was written for, and the reason every boundary below is
// computed from a calendar rather than by subtracting hours: it moves twice a year.
func auckland(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("Pacific/Auckland")
if err != nil {
t.Skip("no timezone database on this machine")
}
return location
}
func TestWeekStartsOnTheLocalMonday(t *testing.T) {
location := auckland(t)
// Thursday 20 August 2026, local afternoon.
now := time.Date(2026, 8, 20, 15, 30, 0, 0, location)
start := weekStartIn(now, location)
if start.Weekday() != time.Monday {
t.Fatalf("week began on %s, want Monday", start.Weekday())
}
if got, want := start.Format("2006-01-02 15:04"), "2026-08-17 00:00"; got != want {
t.Fatalf("week start = %s, want %s", got, want)
}
}
func TestMondayIsItsOwnWeekStart(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 8, 17, 0, 1, 0, 0, location)
if got := weekStartIn(now, location); !got.Equal(time.Date(2026, 8, 17, 0, 0, 0, 0, location)) {
t.Fatalf("Monday's week start = %s, want the same midnight", got)
}
}
// Sunday is the far end of the week, and reading it as day zero would report a whole week of
// viewing as the coming one's — on the very evening the summary is sent.
func TestSundayBelongsToTheWeekThatIsEnding(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 8, 23, 20, 30, 0, 0, location) // Sunday evening
if got, want := weekStartIn(now, location).Format("2006-01-02"), "2026-08-17"; got != want {
t.Fatalf("Sunday's week start = %s, want %s", got, want)
}
}
// The week containing New Zealand's daylight-saving change is 25 hours long, and a boundary
// computed by subtracting 7*24h would land an hour inside the previous Sunday.
func TestAWeekSpanningTheClockChangeStillBeginsAtMidnight(t *testing.T) {
location := auckland(t)
// Daylight saving ends on the first Sunday of April; 5 April 2026 in this zone.
now := time.Date(2026, 4, 5, 21, 0, 0, 0, location)
start := weekStartIn(now, location)
if got, want := start.Format("2006-01-02 15:04"), "2026-03-30 00:00"; got != want {
t.Fatalf("week start across the clock change = %s, want %s", got, want)
}
}
func TestMonthStartIsLocalMidnightOnTheFirst(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 8, 20, 15, 30, 0, 0, location)
if got, want := monthStartIn(now, location).Format("2006-01-02 15:04"), "2026-08-01 00:00"; got != want {
t.Fatalf("month start = %s, want %s", got, want)
}
}
func TestPreviousMonthIsClosedAtBothEnds(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 1, 3, 21, 0, 0, 0, location)
from, to, key := previousMonth(now, location)
if got := from.Format("2006-01-02"); got != "2025-12-01" {
t.Fatalf("previous month began %s, want 2025-12-01", got)
}
if got := to.Format("2006-01-02"); got != "2026-01-01" {
t.Fatalf("previous month ended %s, want 2026-01-01", got)
}
if key != "2025-12" {
t.Fatalf("previous month key = %q, want 2025-12", key)
}
}
// The source key is the only thing preventing a repeat, so two different weeks must never
// produce the same one — the end of December being where a plain year-and-week would collide.
func TestWeekKeysAreDistinctAcrossTheNewYear(t *testing.T) {
location := auckland(t)
seen := map[string]string{}
for day := 0; day < 21; day++ {
now := time.Date(2025, 12, 22, 20, 0, 0, 0, location).AddDate(0, 0, day)
key := weekKey(now, location)
week := weekStartIn(now, location).Format("2006-01-02")
if previous, ok := seen[key]; ok && previous != week {
t.Fatalf("key %q names both the week of %s and the week of %s", key, previous, week)
}
seen[key] = week
}
if len(seen) != 3 {
t.Fatalf("21 days produced %d week keys, want 3", len(seen))
}
}
func TestTheWeeklySummaryIsDueOnlyOnSundayEvening(t *testing.T) {
location := auckland(t)
cases := []struct {
name string
when time.Time
want bool
}{
{"Sunday evening", time.Date(2026, 8, 23, 20, 0, 0, 0, location), true},
{"later on Sunday night", time.Date(2026, 8, 23, 23, 59, 0, 0, location), true},
{"Sunday afternoon", time.Date(2026, 8, 23, 15, 0, 0, 0, location), false},
{"Monday evening", time.Date(2026, 8, 24, 20, 0, 0, 0, location), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := weeklyDigestDue(tc.when, location); got != tc.want {
t.Fatalf("weeklyDigestDue = %v, want %v", got, tc.want)
}
})
}
}
// A summary is worded, not printed. These are the cases somebody actually reads.
func TestWatchDurationWording(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{0, "no time"},
{20 * time.Second, "no time"},
{40 * time.Second, "1 min"},
{45 * time.Minute, "45 min"},
{time.Hour, "1 hour"},
{3 * time.Hour, "3 hours"},
{3*time.Hour + 12*time.Minute, "3h 12m"},
{3*time.Hour + 12*time.Minute + 40*time.Second, "3h 13m"},
}
for _, tc := range cases {
if got := formatWatchDuration(tc.in); got != tc.want {
t.Fatalf("formatWatchDuration(%s) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The month is dropped when it would repeat the week, which is what the first week of a month
// looks like — two identical figures in one sentence read as a fault.
func TestTheWeeklyMessageOmitsAMonthThatSaysNothingNew(t *testing.T) {
same := weeklyDigestMessage(2*time.Hour, 2*time.Hour, "")
if want := "You watched 2 hours this week."; same != want {
t.Fatalf("message = %q, want %q", same, want)
}
more := weeklyDigestMessage(2*time.Hour, 9*time.Hour, "Severance")
want := "You watched 2 hours this week, and 9 hours so far this month. Mostly Severance."
if more != want {
t.Fatalf("message = %q, want %q", more, want)
}
}
func TestTheMonthlyMessageNamesItsMonth(t *testing.T) {
got := monthlyDigestMessage(14*time.Hour+30*time.Minute, "December", "The Bear")
want := "You watched 14h 30m in December. Most of it on The Bear."
if got != want {
t.Fatalf("message = %q, want %q", got, want)
}
}
// A recorded Tracearr id outranks the name, so a viewer renamed in one system keeps their
// figures instead of quietly reporting zero.
func TestWatchTimeIsAttributedByRecordedIdentityBeforeName(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-1", Username: "matt"}}
totals := []store.WatchTimeTotals{
{TracearrUserID: "tr-1", Username: "matthew", WeekMs: 90},
{TracearrUserID: "tr-2", Username: "matt", WeekMs: 5},
}
identities := map[string]store.RecommendationIdentity{
"emby-1": {TracearrUserID: "tr-1", Username: "matthew"},
}
got := attributeWatchTime(accounts, totals, identities)
if got["emby-1"].WeekMs != 90 {
t.Fatalf("attributed %d ms, want the renamed identity's 90", got["emby-1"].WeekMs)
}
}
func TestWatchTimeFallsBackToACaseInsensitiveName(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-1", Username: "Matt"}}
totals := []store.WatchTimeTotals{{Username: "matt", WeekMs: 42}}
got := attributeWatchTime(accounts, totals, nil)
if got["emby-1"].WeekMs != 42 {
t.Fatalf("attributed %d ms, want 42", got["emby-1"].WeekMs)
}
}
// Somebody Tracearr has never heard of gets no entry at all, which is what lets the console
// say "not available" rather than drawing a row of confident zeroes.
func TestAnUnmatchedAccountIsAbsentRatherThanZero(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-9", Username: "nobody"}}
got := attributeWatchTime(accounts, []store.WatchTimeTotals{{Username: "matt"}}, nil)
if _, ok := got["emby-9"]; ok {
t.Fatal("an unmatched account was attributed watch time")
}
if summary := summariseWatchTime(store.WatchTimeTotals{}, false); summary.Matched {
t.Fatal("an unmatched summary claimed a match")
}
}
func TestARangeIsLookedUpTheSameTwoWays(t *testing.T) {
windows := []store.WatchTimeRange{
{TracearrUserID: "tr-1", Username: "matthew", Ms: 900, TopTitle: "Severance"},
{TracearrUserID: "tr-2", Username: "sam", Ms: 60, TopTitle: "Bluey"},
}
byID, byName := indexWatchTimeRanges(windows)
renamed := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{TracearrUserID: "tr-1"}, "matt")
if renamed.Ms != 900 {
t.Fatalf("id lookup found %d ms, want 900", renamed.Ms)
}
byNameOnly := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{}, "SAM")
if byNameOnly.TopTitle != "Bluey" {
t.Fatalf("name lookup found %q, want Bluey", byNameOnly.TopTitle)
}
missing := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{}, "nobody")
if missing.Ms != 0 || missing.TopTitle != "" {
t.Fatalf("an unknown viewer resolved to %+v, want the zero window", missing)
}
}
// A summary somebody has switched off is withdrawn from the list they already have, and the
// unrelated kinds beside it are untouched.
func TestTurningTheSummaryOffHidesTheOnesAlreadySent(t *testing.T) {
notifications := []store.UserNotification{
{Kind: watchTimeWeeklyKind}, {Kind: watchTimeMonthlyKind},
{Kind: "show-return"}, {Kind: "other"},
}
prefs := store.DefaultNotificationPreferences()
prefs.WatchTimeDigest = false
filtered := filterStoredNotifications(notifications, prefs)
if len(filtered) != 2 {
t.Fatalf("kept %d notifications, want 2", len(filtered))
}
for _, notification := range filtered {
if notification.Kind == watchTimeWeeklyKind || notification.Kind == watchTimeMonthlyKind {
t.Fatalf("a watch-time summary survived the switch being off")
}
}
if kept := filterStoredNotifications(notifications, store.DefaultNotificationPreferences()); len(kept) != 4 {
t.Fatalf("the default preferences kept %d of 4 notifications", len(kept))
}
}
+1 -1
View File
@@ -1 +1 @@
0.1.52
0.1.53
+217
View File
@@ -0,0 +1,217 @@
package credits
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
)
// The corpus harness: the only test in this package that reads a media file.
//
// Every other test here pins a pure function against numbers written by hand, which is the
// right shape for a changepoint rule and no shape at all for the question that actually
// matters — does this find the credits. That question needs media with a known answer, so
// this runs the detector over a corpus whose ground truth was fixed when the clips were
// built (`testdata/gen_corpus.sh`) rather than annotated afterwards by looking at what the
// detector said.
//
// It is skipped unless MEMBY_CREDITS_CORPUS points at such a directory, because the corpus
// is media and media does not belong in the repository. Run it as:
//
// MEMBY_CREDITS_CORPUS=/path/to/corpus go test ./internal/credits -run Corpus -v
//
// The synthetic corpus is deliberately not a substitute for real episodes. What it is good
// for is the structural cases — credits over footage, a dark scene before the roll, a
// negative with no credits at all — where the failure is a property of the rule rather than
// of any particular show, and where a real library gives you one example a fortnight.
// truthEntry is one clip and the frame its credits genuinely begin at. A negative carries
// -1, which is a claim in its own right: the detector must find nothing.
type truthEntry struct {
Clip string `json:"clip"`
CreditsStartMs int64 `json:"creditsStartMs"`
}
// corpusResult is one clip's outcome, kept apart from the printing so a future run can emit
// something other than a table without disturbing the measurement.
type corpusResult struct {
Clip string
TruthMs int64
Detected bool
StartMs int64
// PtsStartMs is the same answer read from the stream's own timestamps rather than from
// the sampling arithmetic. Where the two disagree the arithmetic is what is wrong.
Confidence float64
ErrorMs int64
Frames int
Elapsed time.Duration
Verdict string
}
func TestCorpusVisualDetector(t *testing.T) {
dir := strings.TrimSpace(os.Getenv("MEMBY_CREDITS_CORPUS"))
if dir == "" {
t.Skip("set MEMBY_CREDITS_CORPUS to a corpus directory to run the media benchmark")
}
sampler := &Sampler{Binary: os.Getenv("MEMBY_CREDITS_FFMPEG"), Timeout: 2 * time.Minute}
if !sampler.Available() {
t.Skip("ffmpeg is not on the path")
}
truth := loadTruth(t, dir)
detector := &VisualDetector{Sampler: sampler}
results := make([]corpusResult, 0, len(truth))
for _, entry := range truth {
path := filepath.Join(dir, entry.Clip+".mp4")
runtimeMs, err := probeRuntimeMs(path)
if err != nil {
t.Fatalf("probe %s: %v", entry.Clip, err)
}
detection, err := detector.Detect(context.Background(), MediaInfo{
URL: path,
RuntimeMs: runtimeMs,
Window: GenericTailWindow(runtimeMs),
})
if err != nil {
t.Fatalf("detect %s: %v", entry.Clip, err)
}
results = append(results, scoreClip(entry, detection))
}
reportCorpus(t, results)
}
// scoreClip turns one detection into a verdict. The four outcomes are kept distinct rather
// than collapsed into pass/fail because they cost quite different things: a miss is a button
// that never appears, a false positive throws somebody past the end of an episode, and those
// are not the same defect however similar the arithmetic looks.
func scoreClip(entry truthEntry, detection Detection) corpusResult {
result := corpusResult{
Clip: entry.Clip,
TruthMs: entry.CreditsStartMs,
Detected: detection.Found,
StartMs: detection.StartMs,
Confidence: detection.Confidence,
Frames: detection.FramesSampled,
Elapsed: detection.Elapsed,
}
negative := entry.CreditsStartMs < 0
switch {
case negative && !detection.Found:
result.Verdict = "ok (correctly found nothing)"
case negative && detection.Found:
result.Verdict = "FALSE POSITIVE"
case !detection.Found:
result.Verdict = "MISS"
default:
result.ErrorMs = detection.StartMs - entry.CreditsStartMs
result.Verdict = bandFor(result.ErrorMs)
}
return result
}
// bandFor names the accuracy band an error falls in. The bands are the brief's, and the sign
// is kept because early and late are not equally bad: early clips the last line of dialogue,
// late shows the viewer the thing they asked to skip.
func bandFor(errorMs int64) string {
magnitude := errorMs
if magnitude < 0 {
magnitude = -magnitude
}
switch {
case magnitude <= 100:
return "<=100ms"
case magnitude <= 500:
return "<=500ms"
case magnitude <= 1000:
return "<=1s"
case magnitude <= 4000:
return "<=4s"
default:
return "WRONG"
}
}
func reportCorpus(t *testing.T, results []corpusResult) {
t.Helper()
sort.Slice(results, func(a, b int) bool { return results[a].Clip < results[b].Clip })
var report strings.Builder
fmt.Fprintf(&report, "\n%-28s %9s %9s %9s %6s %7s %7s %s\n",
"CLIP", "TRUTH", "FOUND", "ERROR", "CONF", "FRAMES", "TIME", "VERDICT")
var (
within500, positives, falsePositives, misses int
)
for _, result := range results {
found, errorLabel := "-", "-"
if result.Detected {
found = formatMs(result.StartMs)
if result.TruthMs >= 0 {
errorLabel = fmt.Sprintf("%+.3fs", float64(result.ErrorMs)/1000)
}
}
truthLabel := "none"
if result.TruthMs >= 0 {
truthLabel = formatMs(result.TruthMs)
}
fmt.Fprintf(&report, "%-28s %9s %9s %9s %6.2f %7d %6.1fs %s\n",
result.Clip, truthLabel, found, errorLabel, result.Confidence,
result.Frames, result.Elapsed.Seconds(), result.Verdict)
switch {
case result.Verdict == "FALSE POSITIVE":
falsePositives++
case result.Verdict == "MISS":
misses++
case result.TruthMs >= 0:
positives++
if magnitude := result.ErrorMs; magnitude <= 500 && magnitude >= -500 {
within500++
}
}
}
fmt.Fprintf(&report, "\n%d/%d located within 500ms, %d missed, %d false positives\n",
within500, positives+misses, misses, falsePositives)
t.Log(report.String())
}
func loadTruth(t *testing.T, dir string) []truthEntry {
t.Helper()
raw, err := os.ReadFile(filepath.Join(dir, "truth.json"))
if err != nil {
t.Fatalf("read truth: %v", err)
}
var truth []truthEntry
if err := json.Unmarshal(raw, &truth); err != nil {
t.Fatalf("parse truth: %v", err)
}
if len(truth) == 0 {
t.Fatal("corpus truth is empty")
}
return truth
}
func probeRuntimeMs(path string) (int64, error) {
out, err := exec.Command("ffprobe", "-v", "error",
"-show_entries", "format=duration", "-of", "csv=p=0", path).Output()
if err != nil {
return 0, err
}
seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil {
return 0, err
}
return int64(seconds * 1000), nil
}
func formatMs(value int64) string {
return fmt.Sprintf("%d:%06.3f", value/60000, float64(value%60000)/1000)
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Synthetic credits-detection corpus with exact ground truth.
#
# Every clip is 25fps, 640x360, with audio, and every positive puts credits_start
# at exactly frame 1534 = 61.360s. That figure is deliberately not round: at a
# 750ms fine-pass interval anchored to the window start, a boundary at 60.000s is
# itself a sample point, so a detector sampling on that grid scores a perfect zero
# for reasons that have nothing to do with how well it found anything. Off-grid
# truth is what makes the reported error the real error.
#
# Segments are encoded separately and concatenated, so the boundary is a genuine
# frame boundary rather than something a filter approximated.
set -euo pipefail
cd "$(dirname "$0")"
OUT=corpus
rm -rf "$OUT" parts
mkdir -p "$OUT" parts
FONT="C\\:/Windows/Fonts/arial.ttf"
ENC="-c:v libx264 -preset veryfast -pix_fmt yuv420p -g 50 -r 25 -c:a aac -b:a 96k -ar 48000 -ac 2"
Q="-hide_banner -loglevel error -y"
# Frame-exact durations. 1534 frames at 25fps is 61.360s.
D_PROG=61.36 # programme before the credits
D_PROG_A=46.36 # programme before a dark closing scene
D_DARK=15.00 # the dark closing scene
D_CRED=30.00
D_CRED_SHORT=20.00
D_POST=10.00
D_DARKTAIL=30.00
TRUTH_MS=61360
cat > parts/credits.txt <<'EOF'
DIRECTED BY
ALEX MERRIWEATHER
WRITTEN BY
JORDAN HALE
PRODUCED BY
SAM OKONKWO
CAST
RILEY BRENNAN
DANA VOSS
KIT ARMITAGE
MARGO SEELEY
DIRECTOR OF PHOTOGRAPHY
NOOR HADDAD
EDITED BY
TOBY LINDQVIST
MUSIC BY
PRIYA RAGHAVAN
PRODUCTION DESIGNER
ELLIOT SHAW
COSTUME DESIGNER
FRANCES ADEYEMI
CASTING BY
WES TANAKA
UNIT PRODUCTION MANAGER
HANNAH DELACROIX
FIRST ASSISTANT DIRECTOR
OSCAR BRENNAN
EOF
# Audio beds. Programme is broadband and non-stationary the way speech is;
# credits are a steady two-note pad. The distinction a detector can find here is
# stationarity and spectral shape, which is the same distinction real end-credit
# music offers against dialogue — synthetic, but not a different mechanism.
A_PROG="anoisesrc=color=brown:amplitude=0.35:r=48000,tremolo=f=3.5:d=0.8"
A_CRED="sine=frequency=220:r=48000,volume=0.3"
# $1 out $2 duration $3 video lavfi $4 audio lavfi [$5 extra -vf]
seg() {
local extra="${5:-null}"
ffmpeg $Q -f lavfi -i "$3" -f lavfi -i "$4" -t "$2" -vf "$extra" $ENC "parts/$1.mp4"
}
# scrolling credits over a supplied background. $1 out $2 dur $3 vsrc $4 fontcolour
credits_over() {
seg "$1" "$2" "$3" "$A_CRED" \
"drawtext=fontfile='$FONT':textfile=parts/credits.txt:fontcolor=$4:fontsize=15:line_spacing=10:x=(w-tw)/2:y=h-35*t"
}
join() { # $1 out name, rest: part names
local out="$1"; shift
: > parts/list.txt
# Relative to the list file's own directory: a POSIX $(pwd) from Git Bash is
# not a path native ffmpeg can open.
for p in "$@"; do echo "file '$p.mp4'" >> parts/list.txt; done
ffmpeg $Q -f concat -safe 0 -i parts/list.txt -c copy "$OUT/$out.mp4"
}
PROG="testsrc2=s=640x360:r=25"
DARKPROG="color=c=#0d0d10:s=640x360:r=25"
BLACK="color=c=black:s=640x360:r=25"
WHITE="color=c=white:s=640x360:r=25"
echo "building parts..."
seg prog $D_PROG "$PROG" "$A_PROG"
seg progA $D_PROG_A "$PROG" "$A_PROG"
seg dark15 $D_DARK "$DARKPROG" "$A_PROG"
seg darktail $D_DARKTAIL "$DARKPROG" "$A_PROG"
seg post10 $D_POST "$PROG" "$A_PROG"
seg progfade $D_PROG "$PROG" "$A_PROG" "fade=t=out:st=59.86:d=1.5"
credits_over cred_black $D_CRED "$BLACK" white
credits_over cred_short $D_CRED_SHORT "$BLACK" white
credits_over cred_over $D_CRED "$PROG" white
credits_over cred_white $D_CRED "$WHITE" black
# static centred card credits (no scroll)
seg cred_static $D_CRED "$BLACK" "$A_CRED" \
"drawtext=fontfile='$FONT':textfile=parts/credits.txt:fontcolor=white:fontsize=13:line_spacing=6:x=(w-tw)/2:y=(h-th)/2"
echo "assembling clips..."
join hard-cut-black prog cred_black
join fade-to-black progfade cred_black
join credits-over-footage prog cred_over
join bright-credits prog cred_white
join static-card-credits prog cred_static
join dark-scene-then-credits progA dark15 cred_black
join short-credits-postcred prog cred_short post10
join negative-dark-ending progA dark15 darktail
# Ground truth travels with the corpus rather than being written into the
# harness: a truth table kept apart from the media it describes is one that
# silently stops matching when a clip is regenerated.
{
echo '['
first=1
for f in "$OUT"/*.mp4; do
n=$(basename "$f" .mp4)
[ $first -eq 1 ] || echo ','
first=0
if [ "$n" = "negative-dark-ending" ]; then
printf ' {"clip":"%s","creditsStartMs":-1}' "$n"
else
printf ' {"clip":"%s","creditsStartMs":%s}' "$n" "$TRUTH_MS"
fi
done
echo; echo ']'
} > "$OUT/truth.json"
echo
printf '%-28s %-9s %s\n' CLIP DURATION TRUTH
for f in "$OUT"/*.mp4; do
n=$(basename "$f" .mp4)
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
if [ "$n" = "negative-dark-ending" ]; then t="none"; else t="61.360"; fi
printf '%-28s %-9.2f %s\n' "$n" "$d" "$t"
done
+54
View File
@@ -0,0 +1,54 @@
package credits
import (
"context"
"fmt"
"os"
"testing"
"time"
)
func TestDebugDump(t *testing.T) {
path := os.Getenv("MEMBY_DEBUG_CLIP")
if path == "" {
t.Skip("no clip")
}
s := &Sampler{Timeout: 2 * time.Minute}
coarse, err := s.Sample(context.Background(), path, 0, 91380*time.Millisecond, coarseInterval)
if err != nil {
t.Fatal(err)
}
t.Logf("coarse frames=%d", len(coarse))
for i, f := range coarse {
t.Logf(" [%2d] pos=%6d mean=%.3f dark=%.3f edge=%.4f var=%.4f diff=%.4f score=%.3f",
i, f.PositionMs, f.Mean, f.DarkFraction, f.EdgeDensity, f.Variance, f.Diff, creditScore(f))
}
idx, sep, ok := findTransition(coarse, coarseInterval)
t.Logf("coarse transition idx=%d pos=%v sep=%.3f ok=%v", idx, func() int64 {
if ok {
return coarse[idx].PositionMs
}
return -1
}(), sep, ok)
if !ok {
return
}
start := time.Duration(coarse[idx].PositionMs) * time.Millisecond
from := start - fineSpan
if from < 0 {
from = 0
}
to := start + fineSpan
fine, err := s.Sample(context.Background(), path, from, to, fineInterval)
if err != nil {
t.Fatal(err)
}
fmt.Println("fine from", from, "to", to, "frames", len(fine))
fidx, fsep, fok := findTransition(fine, fineInterval)
if fok {
t.Logf("fine transition idx=%d pos=%d sep=%.3f", fidx, fine[fidx].PositionMs, fsep)
} else {
t.Logf("fine transition: NONE (fine pass did not refine)")
}
}
+14 -6
View File
@@ -72,9 +72,15 @@ type JourneyEvent struct {
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId,omitempty"`
ItemName string `json:"itemName,omitempty"`
ItemType string `json:"itemType,omitempty"`
Outcome string `json:"outcome,omitempty"`
// PlaySessionID is Emby's id for the stream a playback step is about, and is empty on
// every other kind of step. It is what joins a journey to the playback the gateway
// already logs, so an operator can tell one viewing of a title from the next without
// the two records having to agree on anything but this.
PlaySessionID string `json:"playSessionId,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
type AnalyticsUser struct {
@@ -306,12 +312,13 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
batch.Queue(`
INSERT INTO journey_events
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
feature, source, target, item_name, item_type, outcome)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
feature, source, target, item_id, item_name, item_type, play_session_id, outcome)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
event.Category, event.Action, event.Screen, event.Feature, event.Source,
event.Target, event.ItemName, event.ItemType, event.Outcome)
event.Target, event.ItemID, event.ItemName, event.ItemType,
event.PlaySessionID, event.Outcome)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
@@ -464,7 +471,8 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) (
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
screen, feature, source, target, item_name, item_type, outcome
screen, feature, source, target, item_id, item_name, item_type,
play_session_id, outcome
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
if err != nil {
@@ -474,7 +482,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
out := []JourneyEvent{}
for rows.Next() {
var v JourneyEvent
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemName, &v.ItemType, &v.Outcome); err != nil {
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemName, &v.ItemType, &v.PlaySessionID, &v.Outcome); err != nil {
return nil, err
}
out = append(out, v)
+18 -9
View File
@@ -23,13 +23,19 @@ type NotificationPreferences struct {
UpdateAlerts bool `json:"updateAlerts"`
LibraryAlerts bool `json:"libraryAlerts"`
SystemAlerts bool `json:"systemAlerts"`
LeadDays int `json:"leadDays"`
// WatchTimeDigest is the weekly viewing summary. It is its own switch rather than part
// of SystemAlerts because it is the only notification here that is about the viewer
// rather than about the library or the server, and somebody who wants to be told a show
// was cancelled may well not want to be told how long they spent watching it.
WatchTimeDigest bool `json:"watchTimeDigest"`
LeadDays int `json:"leadDays"`
}
func DefaultNotificationPreferences() NotificationPreferences {
return NotificationPreferences{
Enabled: true, ShowReturnAlerts: true, SonarrAlerts: true, RadarrAlerts: true,
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true, LeadDays: 7,
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true,
WatchTimeDigest: true, LeadDays: 7,
}
}
@@ -210,10 +216,11 @@ func (s *Store) NotificationPreferences(ctx context.Context, userID string) (Not
prefs := DefaultNotificationPreferences()
err := s.pool.QueryRow(ctx, `
SELECT enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
update_alerts, library_alerts, system_alerts, lead_days
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts, &prefs.RadarrAlerts,
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts, &prefs.LeadDays)
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
&prefs.WatchTimeDigest, &prefs.LeadDays)
if err != nil && !isNoRows(err) {
return prefs, fmt.Errorf("store: notification preferences: %w", err)
}
@@ -232,8 +239,8 @@ func (s *Store) SetNotificationPreferences(
_, err := s.pool.Exec(ctx, `
INSERT INTO user_notification_preferences
(emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
update_alerts, library_alerts, system_alerts, lead_days)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (emby_user_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
show_return_alerts = EXCLUDED.show_return_alerts,
@@ -242,17 +249,19 @@ func (s *Store) SetNotificationPreferences(
update_alerts = EXCLUDED.update_alerts,
library_alerts = EXCLUDED.library_alerts,
system_alerts = EXCLUDED.system_alerts,
watch_time_digest = EXCLUDED.watch_time_digest,
lead_days = EXCLUDED.lead_days,
updated_at = now()`,
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.SonarrAlerts, prefs.RadarrAlerts,
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.LeadDays)
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.WatchTimeDigest,
prefs.LeadDays)
return err
}
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
rows, err := s.pool.Query(ctx, `
SELECT emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
update_alerts, library_alerts, system_alerts, lead_days
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days
FROM user_notification_preferences`)
if err != nil {
return nil, fmt.Errorf("store: list notification preferences: %w", err)
@@ -264,7 +273,7 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti
var userID string
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
&prefs.RadarrAlerts, &prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
&prefs.LeadDays); err != nil {
&prefs.WatchTimeDigest, &prefs.LeadDays); err != nil {
return nil, fmt.Errorf("store: scan notification preferences: %w", err)
}
result[userID] = prefs
+5
View File
@@ -199,6 +199,9 @@ CREATE TABLE IF NOT EXISTS journey_events (
);
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFAULT '';
-- Emby's id for the stream a playback step describes. Empty on every other kind of step,
-- and on every row written before playback steps carried one.
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS play_session_id TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
ON journey_events (emby_user_id, journey_id, sequence);
@@ -243,6 +246,7 @@ CREATE TABLE IF NOT EXISTS user_notification_preferences (
update_alerts BOOLEAN NOT NULL DEFAULT true,
library_alerts BOOLEAN NOT NULL DEFAULT true,
system_alerts BOOLEAN NOT NULL DEFAULT true,
watch_time_digest BOOLEAN NOT NULL DEFAULT true,
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -255,6 +259,7 @@ ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS radarr_alerts
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS update_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS library_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS system_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS watch_time_digest BOOLEAN NOT NULL DEFAULT true;
-- Notifications are materialised so read/dismissed state follows the user to every TV.
-- source_key is deterministic, preventing the same return date from being announced
+180
View File
@@ -0,0 +1,180 @@
package store
import (
"context"
"fmt"
"time"
)
// Watch time is read out of tracearr_sessions rather than stored again.
//
// Tracearr is already the household's record of who watched what and for how long, and the
// import that feeds recommendations has written every one of those rows into Postgres — so
// a second table counting minutes would be a copy of a copy, wrong the moment Tracearr
// corrects a session and needing its own reconciliation to stay honest. Everything below is
// therefore a query, and the only cost of adding this feature is the reading.
//
// watchedMsExpr is the one definition of "how long was this actually watched", and it exists
// in exactly this one place so the console's figure and the digest's figure can never
// disagree. Tracearr reports two overlapping numbers — durationMs, which is aggregate watch
// time, and progressMs, the furthest point reached — and neither is reliably the larger, so
// the greater of the two is taken. It is then capped at the title's own length, because a
// session somebody left running against a title they rewound through can otherwise report
// more watching than the programme contains. A zero total means Tracearr did not say how
// long the title was, and NULLIF hands that case to LEAST as NULL, which Postgres ignores —
// so an unknown length caps nothing rather than capping everything to zero.
const watchedMsExpr = `GREATEST(LEAST(GREATEST(duration_ms, progress_ms), NULLIF(total_duration_ms, 0)), 0)`
// watchedTitleExpr names what was watched the way a person would. An episode is reported as
// its series, for the same reason Session.TitleKey does it: "three hours of Severance" is
// the useful sentence, and "forty minutes of Chikhai Bardo" is a fact about one episode
// nobody asked about.
const watchedTitleExpr = `CASE WHEN lower(media_type) = 'episode' AND show_title <> ''
THEN show_title ELSE media_title END`
// WatchTimeTotals is one Tracearr identity's viewing, in the three windows the console
// shows at once. Username is carried beside the id because the id is what Tracearr calls
// somebody and the name is the only thing that can be matched against an Emby account.
type WatchTimeTotals struct {
TracearrUserID string `json:"tracearrUserId,omitempty"`
Username string `json:"username"`
WeekMs int64 `json:"weekMs"`
MonthMs int64 `json:"monthMs"`
TotalMs int64 `json:"totalMs"`
WeekSessions int `json:"weekSessions"`
MonthSessions int `json:"monthSessions"`
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
}
// WatchTimeRange is one identity's viewing inside a closed window, with the title most of it
// went on. Kept apart from WatchTimeTotals because a window that has *ended* is a different
// question from a running total: it is what the month-end summary reports, and it is the only
// one of the two for which naming a top title is worth an extra pass over the rows.
type WatchTimeRange struct {
TracearrUserID string
Username string
Ms int64
Sessions int
TopTitle string
TopTitleMs int64
}
// TracearrWatchTime totals the two running windows and the lifetime figure in one pass.
//
// One query rather than three because this is read while an operator waits for the accounts
// page, and because the three numbers must describe the same instant — three queries against
// a table an import is writing into could report a week larger than the month containing it.
//
// The boundaries are passed in rather than computed here: a week begins on the household's
// local Monday, and the store has no idea which timezone the household keeps.
func (s *Store) TracearrWatchTime(
ctx context.Context,
weekStart, monthStart time.Time,
) ([]WatchTimeTotals, error) {
rows, err := s.pool.Query(ctx, `
SELECT
max(tracearr_user_id) AS tracearr_user_id,
max(username) AS username,
coalesce(sum(`+watchedMsExpr+`) FILTER (WHERE started_at >= $1), 0)::bigint AS week_ms,
coalesce(sum(`+watchedMsExpr+`) FILTER (WHERE started_at >= $2), 0)::bigint AS month_ms,
coalesce(sum(`+watchedMsExpr+`), 0)::bigint AS total_ms,
count(*) FILTER (WHERE started_at >= $1) AS week_sessions,
count(*) FILTER (WHERE started_at >= $2) AS month_sessions,
max(started_at) AS last_watched_at
FROM tracearr_sessions
WHERE username <> ''
GROUP BY lower(username)
ORDER BY total_ms DESC`,
weekStart, monthStart)
if err != nil {
return nil, fmt.Errorf("store: tracearr watch time: %w", err)
}
defer rows.Close()
out := []WatchTimeTotals{}
for rows.Next() {
var totals WatchTimeTotals
if err := rows.Scan(
&totals.TracearrUserID, &totals.Username, &totals.WeekMs, &totals.MonthMs,
&totals.TotalMs, &totals.WeekSessions, &totals.MonthSessions, &totals.LastWatchedAt,
); err != nil {
return nil, fmt.Errorf("store: scan tracearr watch time: %w", err)
}
out = append(out, totals)
}
return out, rows.Err()
}
// TracearrWatchTimeRange totals a closed window, half-open on the right so two adjacent
// months can never both claim a session started on the stroke of midnight.
func (s *Store) TracearrWatchTimeRange(
ctx context.Context,
from, to time.Time,
) ([]WatchTimeRange, error) {
rows, err := s.pool.Query(ctx, `
WITH watched AS (
SELECT lower(username) AS user_key, tracearr_user_id, username,
`+watchedTitleExpr+` AS title,
`+watchedMsExpr+` AS ms
FROM tracearr_sessions
WHERE username <> '' AND started_at >= $1 AND started_at < $2
),
totals AS (
SELECT user_key, max(tracearr_user_id) AS tracearr_user_id, max(username) AS username,
coalesce(sum(ms), 0)::bigint AS ms, count(*) AS sessions
FROM watched GROUP BY user_key
),
titles AS (
SELECT user_key, title, coalesce(sum(ms), 0)::bigint AS ms
FROM watched WHERE title <> '' GROUP BY user_key, title
),
tops AS (
SELECT DISTINCT ON (user_key) user_key, title, ms
FROM titles ORDER BY user_key, ms DESC, title
)
SELECT totals.tracearr_user_id, totals.username, totals.ms, totals.sessions,
coalesce(tops.title, ''), coalesce(tops.ms, 0)
FROM totals LEFT JOIN tops ON tops.user_key = totals.user_key
ORDER BY totals.ms DESC`,
from, to)
if err != nil {
return nil, fmt.Errorf("store: tracearr watch time range: %w", err)
}
defer rows.Close()
out := []WatchTimeRange{}
for rows.Next() {
var window WatchTimeRange
if err := rows.Scan(
&window.TracearrUserID, &window.Username, &window.Ms,
&window.Sessions, &window.TopTitle, &window.TopTitleMs,
); err != nil {
return nil, fmt.Errorf("store: scan tracearr watch time range: %w", err)
}
out = append(out, window)
}
return out, rows.Err()
}
// TracearrIdentities is every Emby account the recommendation builder has already matched to
// a Tracearr one. Watch time is attributed by username, which is the identity the two systems
// genuinely share — but a household that has renamed somebody in one and not the other would
// silently lose their figures, and this map is what lets the id carry them instead.
func (s *Store) TracearrIdentities(ctx context.Context) (map[string]RecommendationIdentity, error) {
rows, err := s.pool.Query(ctx, `
SELECT emby_user_id, tracearr_user_id, tracearr_username
FROM recommendation_user_profiles
WHERE tracearr_user_id <> '' OR tracearr_username <> ''`)
if err != nil {
return nil, fmt.Errorf("store: tracearr identities: %w", err)
}
defer rows.Close()
out := map[string]RecommendationIdentity{}
for rows.Next() {
var embyUserID string
var identity RecommendationIdentity
if err := rows.Scan(&embyUserID, &identity.TracearrUserID, &identity.Username); err != nil {
return nil, fmt.Errorf("store: scan tracearr identity: %w", err)
}
out[embyUserID] = identity
}
return out, rows.Err()
}