0.2.75
This commit is contained in:
@@ -488,6 +488,55 @@ behind it. Things to preserve:
|
|||||||
enabling the scanner would announce every show that had already ended as new news, and
|
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.
|
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
|
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
|
||||||
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
|
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
|
||||||
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` →
|
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
|
than none, since it opens a connection nothing will use and leaves the one that matters
|
||||||
cold.
|
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
|
**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
|
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.
|
slow older Progress request cannot complete after Stop and move Emby's saved playhead back.
|
||||||
|
|||||||
-11
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
|||||||
rel="icon"
|
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"
|
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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ export function duration(ms: number | undefined | null): string {
|
|||||||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
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". */
|
/** interval describes a schedule, where "3600s" is a worse answer than "every hour". */
|
||||||
export function interval(seconds: number | undefined | null): string {
|
export function interval(seconds: number | undefined | null): string {
|
||||||
if (!seconds || seconds <= 0) return 'on request only';
|
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 =>
|
export const recent = (value: string | undefined | null): boolean =>
|
||||||
Boolean(value) && Date.now() - new Date(value as string).getTime() < ACTIVE_MS;
|
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
|
/* 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 —
|
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
|
and only the second is worth an operator's attention.
|
||||||
in ordinary use that happens to be off, and red is one that has stopped checking in for
|
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
|
three hours. A device with no timestamp at all is red: never seen is the strongest
|
||||||
version of not seen. */
|
version of not seen. */
|
||||||
export function presence(value: string | undefined | null): { tone: Tone; label: string } {
|
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' };
|
if (!seen) return { tone: 'bad', label: 'never seen' };
|
||||||
const age = Date.now() - seen;
|
const age = Date.now() - seen;
|
||||||
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
|
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' };
|
return { tone: 'bad', label: 'not seen lately' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useAction, useQuery } from '../lib/hooks';
|
import { useAction, useQuery } from '../lib/hooks';
|
||||||
import { useToast } from '../lib/toast';
|
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 {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Loading,
|
Loading,
|
||||||
PageHead,
|
PageHead,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tiles,
|
||||||
Toggle,
|
Toggle,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import type { DeviceVersion } from '../api/types';
|
import type { DeviceVersion } from '../api/types';
|
||||||
@@ -78,9 +79,24 @@ interface NotificationPreferences {
|
|||||||
updateAlerts: boolean;
|
updateAlerts: boolean;
|
||||||
libraryAlerts: boolean;
|
libraryAlerts: boolean;
|
||||||
systemAlerts: boolean;
|
systemAlerts: boolean;
|
||||||
|
watchTimeDigest: boolean;
|
||||||
leadDays: number;
|
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 {
|
interface AccountDetail {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -97,6 +113,7 @@ interface AccountDetail {
|
|||||||
preferences?: Record<string, unknown>;
|
preferences?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
notifications: NotificationPreferences;
|
notifications: NotificationPreferences;
|
||||||
|
watchTime?: WatchTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountsPayload {
|
interface AccountsPayload {
|
||||||
@@ -338,6 +355,53 @@ export function AccountPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</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
|
<Card
|
||||||
title="Notifications"
|
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."
|
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 })
|
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
|
<Toggle
|
||||||
label="Service status"
|
label="Service status"
|
||||||
hint="Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies."
|
hint="Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery } from '../lib/hooks';
|
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 { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
|
||||||
import type { KnownClient } from '../api/types';
|
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
|
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. */
|
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 {
|
interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -16,6 +31,7 @@ interface Account {
|
|||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
devices: KnownClient[] | null;
|
devices: KnownClient[] | null;
|
||||||
recommendations?: { prompted?: boolean; completed?: boolean };
|
recommendations?: { prompted?: boolean; completed?: boolean };
|
||||||
|
watchTime?: WatchTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountsResponse {
|
interface AccountsResponse {
|
||||||
@@ -32,6 +48,10 @@ export function AccountsPage() {
|
|||||||
const queued = accounts.filter(
|
const queued = accounts.filter(
|
||||||
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
|
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
|
||||||
).length;
|
).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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -60,6 +80,16 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
|
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
|
||||||
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
|
{ 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: 'prompt queued', tone: 'warn' as const }
|
||||||
: { label: 'not invited', tone: undefined };
|
: { label: 'not invited', tone: undefined };
|
||||||
const seen = presence(account.lastSeen);
|
const seen = presence(account.lastSeen);
|
||||||
|
const watched = account.watchTime;
|
||||||
return (
|
return (
|
||||||
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
|
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
|
||||||
<span className="list-main">
|
<span className="list-main">
|
||||||
@@ -90,10 +121,17 @@ export function AccountsPage() {
|
|||||||
<span className="list-meta">
|
<span className="list-meta">
|
||||||
{num(list.length)} device{list.length === 1 ? '' : 's'}
|
{num(list.length)} device{list.length === 1 ? '' : 's'}
|
||||||
{active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)}
|
{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>
|
||||||
</span>
|
</span>
|
||||||
<span className="list-actions">
|
<span className="list-actions">
|
||||||
|
{watched?.matched ? <Tag tone="data">{watchTime(watched.weekMs)}</Tag> : null}
|
||||||
<Tag tone={state.tone}>{state.label}</Tag>
|
<Tag tone={state.tone}>{state.label}</Tag>
|
||||||
<span className="crumb">Manage</span>
|
<span className="crumb">Manage</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ interface JourneyEvent {
|
|||||||
feature?: string;
|
feature?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
|
itemId?: string;
|
||||||
itemName?: string;
|
itemName?: string;
|
||||||
itemType?: string;
|
itemType?: string;
|
||||||
|
playSessionId?: string;
|
||||||
outcome?: 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);
|
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
|
const detail = (event: JourneyEvent) => event.itemName
|
||||||
? `${label(event.itemType)} · ${event.itemName}`
|
? `${label(event.itemType)} · ${event.itemName}`
|
||||||
: event.source && event.target ? `${label(event.source)} → ${label(event.target)}` : place(event);
|
: event.source && event.target ? `${label(event.source)} → ${label(event.target)}` : place(event);
|
||||||
const verb = (event: JourneyEvent) => ({
|
const verb = (event: JourneyEvent) => ({
|
||||||
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
|
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
|
||||||
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
|
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
|
||||||
request: event.category === 'playback' ? 'Started watching' : 'Requested',
|
request: event.category === 'playback' ? 'Asked to watch' : 'Requested',
|
||||||
stop: 'Stopped watching', start: 'Started', complete: 'Completed',
|
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));
|
}[event.action] ?? label(event.action));
|
||||||
|
|
||||||
function outcome(events: JourneyEvent[]) {
|
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;
|
const explicit = [...events].reverse().find((event) => event.outcome)?.outcome;
|
||||||
if (explicit === 'success' || explicit === 'completed') return { label: label(explicit), tone: 'ok' as const };
|
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 };
|
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}>
|
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>
|
<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-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="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 className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
const FEATURE_CATALOGUE = [
|
const FEATURE_CATALOGUE = [
|
||||||
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
|
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
|
||||||
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
|
'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
|
/** The wire values are ids; this is the render, so it is spelled — the same boundary the
|
||||||
|
|||||||
+623
-157
@@ -9,127 +9,384 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { num } from '../lib/format';
|
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';
|
import type { LogEvent, LogResponse } from '../api/types';
|
||||||
|
|
||||||
/* The live server log.
|
/* The live server log.
|
||||||
*
|
*
|
||||||
* Network delivery is already cursor based: each server record crosses the wire once.
|
* Network delivery is cursor based: each server record crosses the wire once. Rendering is
|
||||||
* Rendering is virtualised as well, so retaining and filtering thousands of records does
|
* virtualised, so retaining and filtering thousands of records does not mean mounting
|
||||||
* not mean mounting thousands of details trees. Only the rows around the viewport exist
|
* thousands of rows — only those around the viewport exist in the DOM.
|
||||||
* in the DOM; selecting one opens its complete structured data below the window. */
|
*
|
||||||
|
* 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 RETAIN = 20_000;
|
||||||
const POLL_MS = 5_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 HEADER_HEIGHT = 31;
|
||||||
const OVERSCAN = 8;
|
const OVERSCAN = 10;
|
||||||
|
|
||||||
const FIELD_ORDER = [
|
const LEVELS = [
|
||||||
'component',
|
{ value: 'TRACE', label: 'Everything' },
|
||||||
'user',
|
{ value: 'DEBUG', label: 'Debug+' },
|
||||||
'device',
|
{ value: 'INFO', label: 'Info+' },
|
||||||
'client',
|
{ value: 'WARN', label: 'Warnings+' },
|
||||||
'protocol',
|
{ value: 'ERROR', label: 'Errors only' },
|
||||||
'method',
|
|
||||||
'path',
|
|
||||||
'status',
|
|
||||||
'duration',
|
|
||||||
'version',
|
|
||||||
'gateway_version',
|
|
||||||
];
|
];
|
||||||
const FIELD_RANK = new Map(FIELD_ORDER.map((key, index) => [key, index]));
|
|
||||||
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' });
|
|
||||||
|
|
||||||
interface CachedEvent {
|
const STATUS_BANDS = [
|
||||||
fields: [string, unknown][];
|
{ value: '', label: 'Any result' },
|
||||||
summary: string;
|
{ value: '2xx', label: 'Success (2xx)' },
|
||||||
haystack: string;
|
{ value: '3xx', label: 'Redirect (3xx)' },
|
||||||
occurred: string;
|
{ 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
|
const SLOWER = [
|
||||||
// and search indexing the same lifetime without adding private fields to JSON exports.
|
{ value: 0, label: 'Any duration' },
|
||||||
const eventCache = new WeakMap<LogEvent, CachedEvent>();
|
{ value: 100, label: 'Slower than 100 ms' },
|
||||||
|
{ value: 500, label: 'Slower than 500 ms' },
|
||||||
function cached(event: LogEvent): CachedEvent {
|
{ value: 1000, label: 'Slower than 1 s' },
|
||||||
const existing = eventCache.get(event);
|
{ value: 3000, label: 'Slower than 3 s' },
|
||||||
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 readableKey = (key: string) => key.replace(/_/g, ' ');
|
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,
|
event,
|
||||||
index,
|
view,
|
||||||
|
top,
|
||||||
|
height,
|
||||||
|
selected,
|
||||||
onInspect,
|
onInspect,
|
||||||
|
onFilter,
|
||||||
}: {
|
}: {
|
||||||
event: LogEvent;
|
event: LogEvent;
|
||||||
index: number;
|
view: Shaped;
|
||||||
|
top: number;
|
||||||
|
height: number;
|
||||||
|
selected: boolean;
|
||||||
onInspect: (sequence: number) => void;
|
onInspect: (sequence: number) => void;
|
||||||
|
onFilter: (patch: Partial<LogFilters>) => void;
|
||||||
}) {
|
}) {
|
||||||
const display = cached(event);
|
const slow = view.durationMs !== null ? durationTone(view.durationMs) : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="logline"
|
className="logrow"
|
||||||
data-level={event.level}
|
data-level={view.level}
|
||||||
data-virtual="true"
|
data-selected={selected || undefined}
|
||||||
style={{ transform: `translateY(${index * ROW_HEIGHT}px)` }}
|
style={{ transform: `translateY(${top}px)`, height: `${height}px` }}
|
||||||
>
|
>
|
||||||
<time title={event.occurredAt}>
|
<time className="logrow-time" title={event.occurredAt}>
|
||||||
{display.occurred}
|
{view.time}
|
||||||
<small>#{event.sequence}</small>
|
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="logattrs-button"
|
className="logrow-summary"
|
||||||
title={display.summary || 'No structured details'}
|
title={view.detail || view.summary}
|
||||||
onClick={() => onInspect(event.sequence)}
|
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>
|
</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>
|
</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() {
|
export function LogsPage() {
|
||||||
const [records, setRecords] = useState<LogEvent[]>([]);
|
const [records, setRecords] = useState<LogEvent[]>([]);
|
||||||
const [dropped, setDropped] = useState(0);
|
const [dropped, setDropped] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [level, setLevel] = useState('INFO');
|
const [held, setHeld] = useState(0);
|
||||||
const [search, setSearch] = useState('');
|
const [filters, setFilters] = useState<LogFilters>(EMPTY_FILTERS);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [viewport, setViewport] = useState({ top: 0, height: 600 });
|
const [viewport, setViewport] = useState({ top: 0, height: 600 });
|
||||||
|
const [atTail, setAtTail] = useState(true);
|
||||||
const [selectedSequence, setSelectedSequence] = useState<number | null>(null);
|
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 cursor = useRef(0);
|
||||||
const fetching = useRef(false);
|
const fetching = useRef(false);
|
||||||
const viewGeneration = useRef(0);
|
const viewGeneration = useRef(0);
|
||||||
const view = useRef<HTMLDivElement>(null);
|
const view = useRef<HTMLDivElement>(null);
|
||||||
const pinned = useRef(true);
|
const pinned = useRef(true);
|
||||||
const scrollFrame = useRef<number | undefined>(undefined);
|
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 () => {
|
const drain = useCallback(async () => {
|
||||||
if (paused || fetching.current || document.hidden) return;
|
if (fetching.current || document.hidden) return;
|
||||||
fetching.current = true;
|
fetching.current = true;
|
||||||
const generation = viewGeneration.current;
|
const generation = viewGeneration.current;
|
||||||
const batch: LogEvent[] = [];
|
const batch: LogEvent[] = [];
|
||||||
@@ -148,23 +405,27 @@ export function LogsPage() {
|
|||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setError(cause instanceof Error ? cause.message : String(cause));
|
setError(cause instanceof Error ? cause.message : String(cause));
|
||||||
} finally {
|
} finally {
|
||||||
// One React update for a complete catch-up prevents the initial 5,000-record drain
|
// One React update for a complete catch-up prevents the initial drain from redrawing
|
||||||
// from redrawing the page once per network page.
|
// the page once per network page.
|
||||||
if (batch.length > 0 && generation === viewGeneration.current) {
|
if (batch.length > 0 && generation === viewGeneration.current) {
|
||||||
setRecords((current) => {
|
if (pausedRef.current) {
|
||||||
const next = current.concat(batch);
|
holding.current = holding.current.concat(batch);
|
||||||
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
|
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) {
|
if (droppedInDrain > 0 && generation === viewGeneration.current) {
|
||||||
setDropped((current) => current + droppedInDrain);
|
setDropped((current) => current + droppedInDrain);
|
||||||
}
|
}
|
||||||
fetching.current = false;
|
fetching.current = false;
|
||||||
}
|
}
|
||||||
}, [paused]);
|
}, [admit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
@@ -181,46 +442,121 @@ export function LogsPage() {
|
|||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
document.removeEventListener('visibilitychange', visibilityChanged);
|
document.removeEventListener('visibilitychange', visibilityChanged);
|
||||||
};
|
};
|
||||||
}, [drain, paused]);
|
}, [drain]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
// Both halves set the ref straight away as well as the state. A drain landing between
|
||||||
const minimum = RANKS[level] ?? 20;
|
// the click and the commit would otherwise file its batch on the wrong side of the
|
||||||
return records.filter(
|
// pause — held records with the button already reading "Pause", which nothing would
|
||||||
(event) =>
|
// ever flush.
|
||||||
(RANKS[event.level] ?? 0) >= minimum &&
|
const pause = useCallback(() => {
|
||||||
(!deferredSearch || cached(event).haystack.includes(deferredSearch)),
|
pausedRef.current = true;
|
||||||
);
|
setPaused(true);
|
||||||
}, [records, level, deferredSearch]);
|
}, []);
|
||||||
|
|
||||||
|
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 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(
|
const selected = useMemo(
|
||||||
() => records.find((event) => event.sequence === selectedSequence),
|
() => records.find((event) => event.sequence === selectedSequence),
|
||||||
[records, 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(() => {
|
useLayoutEffect(() => {
|
||||||
const node = view.current;
|
const node = view.current;
|
||||||
if (!node || !pinned.current) return;
|
if (!node || !pinned.current) return;
|
||||||
node.scrollTop = node.scrollHeight;
|
node.scrollTop = node.scrollHeight;
|
||||||
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
||||||
}, [lastSequence, deferredSearch, level]);
|
}, [lastSequence, layout.total]);
|
||||||
|
|
||||||
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
|
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
const node = view.current;
|
const node = view.current;
|
||||||
if (!node) return;
|
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);
|
window.cancelAnimationFrame(scrollFrame.current ?? 0);
|
||||||
scrollFrame.current = window.requestAnimationFrame(() => {
|
scrollFrame.current = window.requestAnimationFrame(() => {
|
||||||
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
||||||
@@ -228,7 +564,7 @@ export function LogsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exportJson = () => {
|
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');
|
const link = document.createElement('a');
|
||||||
link.href = URL.createObjectURL(blob);
|
link.href = URL.createObjectURL(blob);
|
||||||
link.download = `memby-events-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
link.download = `memby-events-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||||
@@ -236,39 +572,129 @@ export function LogsPage() {
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const available = useMemo(() => facets(records), [records]);
|
||||||
|
const chips = activeChips(filters, available.services);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title="Server logs" intro="Structured gateway events as they happen." />
|
<PageHead title="Server logs" intro="Structured gateway events as they happen." />
|
||||||
<Banner message={error} />
|
<Banner message={error} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="filters">
|
<div className="logbar">
|
||||||
<Field label="Level">
|
<div className="logbar-filters">
|
||||||
<select value={level} onChange={(event) => setLevel(event.target.value)}>
|
<select
|
||||||
<option value="DEBUG">Debug and above</option>
|
aria-label="Service"
|
||||||
<option value="INFO">Info and above</option>
|
value={filters.service}
|
||||||
<option value="WARN">Warnings and errors</option>
|
onChange={(event) => patch({ service: event.target.value, component: '' })}
|
||||||
<option value="ERROR">Errors only</option>
|
>
|
||||||
|
<option value="">All services</option>
|
||||||
|
{available.services.map((service) => (
|
||||||
|
<option key={service.key} value={service.key}>
|
||||||
|
{service.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
<select
|
||||||
<Field label="Filter" grow>
|
aria-label="Component"
|
||||||
<input
|
value={filters.component}
|
||||||
type="search"
|
onChange={(event) => patch({ component: event.target.value })}
|
||||||
value={search}
|
>
|
||||||
placeholder="Person, television, title, component, path…"
|
<option value="">All components</option>
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
{available.components.map((component) => (
|
||||||
/>
|
<option key={component} value={component}>
|
||||||
</Field>
|
{component}
|
||||||
<div className="filter-actions">
|
</option>
|
||||||
<Button onClick={() => setPaused((current) => !current)} icon={paused ? 'play' : 'clock'}>
|
))}
|
||||||
{paused ? 'Resume' : 'Pause'}
|
</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>
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="quiet"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// A page already in flight may finish after this click. Advancing the
|
// 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
|
// generation makes that batch part of the cleared past, not a flash of old
|
||||||
// old lines reappearing after the view was emptied.
|
// lines reappearing after the view was emptied.
|
||||||
viewGeneration.current += 1;
|
viewGeneration.current += 1;
|
||||||
|
holding.current = [];
|
||||||
|
setHeld(0);
|
||||||
setRecords([]);
|
setRecords([]);
|
||||||
setDropped(0);
|
setDropped(0);
|
||||||
setSelectedSequence(null);
|
setSelectedSequence(null);
|
||||||
@@ -276,57 +702,97 @@ export function LogsPage() {
|
|||||||
>
|
>
|
||||||
Clear view
|
Clear view
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-label="Server events">
|
{chips.length ? (
|
||||||
<div className="loghead" aria-hidden="true">
|
<div className="logchips">
|
||||||
<span>Time</span>
|
{chips.map((chip) => (
|
||||||
<span>Level</span>
|
<button
|
||||||
<span>Event</span>
|
key={chip.key}
|
||||||
<span>Details</span>
|
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>
|
</div>
|
||||||
{filtered.length === 0 ? (
|
) : null}
|
||||||
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
|
|
||||||
) : (
|
<div className="logshell">
|
||||||
<div className="logbody" style={{ height: `${filtered.length * ROW_HEIGHT}px` }}>
|
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-label="Server events">
|
||||||
{windowed.map((event, offset) => (
|
<div className="loghead" aria-hidden="true">
|
||||||
<LogLine key={event.sequence} event={event} index={first + offset} onInspect={setSelectedSequence} />
|
<span>Time</span>
|
||||||
))}
|
<span>Level</span>
|
||||||
|
<span>Service</span>
|
||||||
|
<span>Event</span>
|
||||||
|
<span>Result</span>
|
||||||
|
<span>Duration</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
{num(records.length)} retained · {num(filtered.length)} matching
|
{num(records.length)} retained · {num(filtered.length)} matching
|
||||||
{filtered.length ? ` · ${num(windowed.length)} rows mounted` : ''}
|
{filtered.length ? ` · ${num(windowed.length)} rows mounted` : ''}
|
||||||
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
|
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
|
||||||
{paused ? ' · paused' : ''}
|
{paused ? ` · paused${held ? `, ${num(held)} held` : ''}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<section className="log-inspector" aria-label={`Log record ${selected.sequence}`}>
|
<Drawer event={selected} view={shape(selected)} onClose={() => setSelectedSequence(null)} />
|
||||||
<div className="log-inspector-head">
|
) : records.length ? (
|
||||||
<div>
|
<Note>Select a row to see the full record — request, context, diagnostics and raw event.</Note>
|
||||||
<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>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+513
-105
@@ -22,7 +22,12 @@
|
|||||||
which is what lets a page distinguish one kind of thing from another without every
|
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
|
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
|
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 {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -41,6 +46,13 @@
|
|||||||
--accent: #2ea043;
|
--accent: #2ea043;
|
||||||
--accent-ink: #56d364;
|
--accent-ink: #56d364;
|
||||||
--accent-wash: rgba(46, 160, 67, 0.16);
|
--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: #e5534b;
|
||||||
--danger-ink: #ff9b94;
|
--danger-ink: #ff9b94;
|
||||||
--danger-wash: rgba(229, 83, 75, 0.13);
|
--danger-wash: rgba(229, 83, 75, 0.13);
|
||||||
@@ -1030,6 +1042,10 @@ a.tile:hover {
|
|||||||
background: var(--accent-wash);
|
background: var(--accent-wash);
|
||||||
color: var(--accent-ink);
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
.glyph[data-tone="idle"] {
|
||||||
|
background: var(--idle-wash);
|
||||||
|
color: var(--idle-ink);
|
||||||
|
}
|
||||||
.glyph[data-tone="warn"] {
|
.glyph[data-tone="warn"] {
|
||||||
background: var(--warn-wash);
|
background: var(--warn-wash);
|
||||||
color: var(--warn-ink);
|
color: var(--warn-ink);
|
||||||
@@ -1078,6 +1094,11 @@ a.tile:hover {
|
|||||||
background: var(--accent-wash);
|
background: var(--accent-wash);
|
||||||
color: var(--accent-ink);
|
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"] {
|
.tag[data-tone="warn"] {
|
||||||
border-color: rgba(239, 196, 107, 0.35);
|
border-color: rgba(239, 196, 107, 0.35);
|
||||||
background: var(--warn-wash);
|
background: var(--warn-wash);
|
||||||
@@ -1119,6 +1140,9 @@ a.tile:hover {
|
|||||||
.chip[data-tone="ok"] {
|
.chip[data-tone="ok"] {
|
||||||
color: var(--accent-ink);
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
.chip[data-tone="idle"] {
|
||||||
|
color: var(--idle-ink);
|
||||||
|
}
|
||||||
.chip[data-tone="warn"] {
|
.chip[data-tone="warn"] {
|
||||||
color: var(--warn-ink);
|
color: var(--warn-ink);
|
||||||
}
|
}
|
||||||
@@ -1744,6 +1768,109 @@ select {
|
|||||||
|
|
||||||
/* ---------- log / code ---------- */
|
/* ---------- 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 {
|
.logview {
|
||||||
height: 62vh;
|
height: 62vh;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
@@ -1751,17 +1878,24 @@ select {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: #080a0e;
|
background: #080a0e;
|
||||||
font: 12px/1.6 var(--mono);
|
font: 12px/1.5 var(--sans);
|
||||||
contain: layout paint style;
|
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 {
|
.loghead {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
display: grid;
|
align-items: center;
|
||||||
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
|
height: 31px;
|
||||||
gap: 12px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: #10131a;
|
background: #10131a;
|
||||||
color: var(--quiet);
|
color: var(--quiet);
|
||||||
@@ -1769,142 +1903,413 @@ select {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .08em;
|
letter-spacing: .08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
min-width: 760px;
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
.loghead > :last-child {
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.logbody {
|
.logbody {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 760px;
|
min-width: 900px;
|
||||||
}
|
}
|
||||||
.logline {
|
|
||||||
display: flex;
|
/* The date is a divider between days rather than a column repeated on every row. A log
|
||||||
gap: 12px;
|
watched live is almost always one day; printing the date 20,000 times to cover the
|
||||||
padding: 3px 12px;
|
handful of rows where it changes is the trade the compact timestamp exists to avoid. */
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
.logday {
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.logline[data-virtual="true"] {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 48px;
|
height: 26px;
|
||||||
padding-top: 4px;
|
padding: 0 12px;
|
||||||
padding-bottom: 4px;
|
background: linear-gradient(to bottom, rgba(255, 255, 255, .03), transparent);
|
||||||
white-space: nowrap;
|
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;
|
contain: strict;
|
||||||
}
|
}
|
||||||
.logline:hover {
|
.logrow:hover {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, .035);
|
||||||
}
|
}
|
||||||
.logline time {
|
.logrow[data-selected] {
|
||||||
flex: 0 0 auto;
|
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);
|
color: var(--quiet);
|
||||||
|
font: 11px/1 var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.logline time small {
|
|
||||||
display: block;
|
/* Every structured value in the table is also the control that filters to it. They are
|
||||||
color: var(--muted);
|
buttons rather than links because nothing navigates: the table narrows in place. */
|
||||||
font-size: 10px;
|
.logfacet {
|
||||||
}
|
height: auto;
|
||||||
.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;
|
|
||||||
min-width: 0;
|
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;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: none;
|
||||||
color: var(--quiet);
|
color: inherit;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.logattrs-button:hover:not(:disabled) {
|
.logfacet:hover:not(:disabled) {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: none;
|
||||||
color: var(--text);
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
.logattrs-button:focus-visible {
|
.logfacet:focus-visible {
|
||||||
outline: 1px solid var(--accent);
|
outline: 1px solid var(--accent);
|
||||||
outline-offset: 2px;
|
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;
|
margin-top: 12px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.log-inspector-head {
|
.logdrawer-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.log-inspector-head > div {
|
.logdrawer-head > div:first-child {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.log-inspector-head b,
|
.logdrawer-head b {
|
||||||
.log-inspector-head span {
|
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.log-inspector-head span {
|
.logdrawer-place {
|
||||||
margin-top: 3px;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0;
|
||||||
color: var(--quiet);
|
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;
|
display: grid;
|
||||||
grid-template-columns: minmax(130px, max-content) minmax(0, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
gap: 5px 14px;
|
gap: 16px 24px;
|
||||||
margin: 12px 0 0;
|
margin-top: 12px;
|
||||||
padding-top: 10px;
|
}
|
||||||
border-top: 1px solid var(--line);
|
.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);
|
font: 12px/1.5 var(--mono);
|
||||||
}
|
}
|
||||||
.log-inspector dt { color: var(--muted); }
|
.logdrawer-section dt { color: var(--muted); }
|
||||||
.log-inspector dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
.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 {
|
.logdetails {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -2243,7 +2648,11 @@ pre.code {
|
|||||||
letter-spacing: 0.02em;
|
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 {
|
.dot-state {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -2254,6 +2663,9 @@ pre.code {
|
|||||||
.dot-state[data-tone="ok"] {
|
.dot-state[data-tone="ok"] {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
.dot-state[data-tone="idle"] {
|
||||||
|
background: var(--idle);
|
||||||
|
}
|
||||||
.dot-state[data-tone="warn"] {
|
.dot-state[data-tone="warn"] {
|
||||||
background: var(--warn);
|
background: var(--warn);
|
||||||
}
|
}
|
||||||
@@ -2873,18 +3285,14 @@ details summary {
|
|||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
padding: 13px 0;
|
padding: 13px 0;
|
||||||
}
|
}
|
||||||
.loghead,
|
/* The column collapse itself is width-driven and lives beside the table's own rules;
|
||||||
.logbody {
|
what a touch device needs on top of it is room to scroll and controls it can reach. */
|
||||||
min-width: 680px;
|
|
||||||
}
|
|
||||||
.loghead,
|
|
||||||
.logline[data-virtual="true"] {
|
|
||||||
grid-template-columns: 128px 44px minmax(160px, 1fr) minmax(220px, 1.4fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.logview {
|
.logview {
|
||||||
height: min(66dvh, 720px);
|
height: min(66dvh, 720px);
|
||||||
}
|
}
|
||||||
|
.logbar-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
|
|||||||
+21
-10
@@ -8,6 +8,10 @@ plugins {
|
|||||||
id("androidx.baselineprofile")
|
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=...).
|
// 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.
|
// 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()
|
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
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.74"
|
val defaultVersionName = "0.2.75"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
@@ -235,7 +239,7 @@ dependencies {
|
|||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
// Compose for TV
|
// Compose for TV
|
||||||
implementation("androidx.tv:tv-material:1.0.0")
|
implementation("androidx.tv:tv-material:1.1.0")
|
||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
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")
|
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||||
|
|
||||||
// DataStore (persisted settings)
|
// 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
|
// Media3 / ExoPlayer for in-app playback
|
||||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
// One version for the whole Media3 line, including the Jellyfin FFmpeg extension below.
|
||||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
|
// That extension is compiled against media3-exoplayer and reached reflectively through
|
||||||
implementation("androidx.media3:media3-ui:1.5.1")
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// server re-encode the video beside it. This build targets Media3 1.9.x; Jellyfin's
|
||||||
// 1.5.0 extension is the matching published binary for that line.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class JourneyAnalytics(
|
|||||||
private val userId: String,
|
private val userId: String,
|
||||||
private val now: () -> Long = System::currentTimeMillis,
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
private val journeyId: String = UUID.randomUUID().toString(),
|
private val journeyId: String = UUID.randomUUID().toString(),
|
||||||
) {
|
) : JourneySink {
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
private val buffer = ArrayList<GatewayJourneyEvent>()
|
private val buffer = ArrayList<GatewayJourneyEvent>()
|
||||||
private var sequence = 0
|
private var sequence = 0
|
||||||
@@ -27,16 +27,18 @@ class JourneyAnalytics(
|
|||||||
// a process/session contribute exactly one home opening.
|
// a process/session contribute exactly one home opening.
|
||||||
init { track("session", "home_open", screen = "home", feature = "app") }
|
init { track("session", "home_open", screen = "home", feature = "app") }
|
||||||
|
|
||||||
fun track(
|
override fun track(
|
||||||
category: String,
|
category: String,
|
||||||
action: String,
|
action: String,
|
||||||
screen: String = "",
|
screen: String,
|
||||||
feature: String = "",
|
feature: String,
|
||||||
source: String = "",
|
source: String,
|
||||||
target: String = "",
|
target: String,
|
||||||
itemName: String = "",
|
itemId: String,
|
||||||
itemType: String = "",
|
itemName: String,
|
||||||
outcome: String = "",
|
itemType: String,
|
||||||
|
playSessionId: String,
|
||||||
|
outcome: String,
|
||||||
) = synchronized(lock) {
|
) = synchronized(lock) {
|
||||||
if (ended) return@synchronized
|
if (ended) return@synchronized
|
||||||
buffer += GatewayJourneyEvent(
|
buffer += GatewayJourneyEvent(
|
||||||
@@ -49,12 +51,17 @@ class JourneyAnalytics(
|
|||||||
feature = clean(feature),
|
feature = clean(feature),
|
||||||
source = clean(source),
|
source = clean(source),
|
||||||
target = clean(target),
|
target = clean(target),
|
||||||
|
itemId = clean(itemId),
|
||||||
itemName = cleanName(itemName),
|
itemName = cleanName(itemName),
|
||||||
itemType = clean(itemType),
|
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),
|
outcome = clean(outcome),
|
||||||
occurredAt = timestamp(),
|
occurredAt = timestamp(),
|
||||||
)
|
)
|
||||||
if (buffer.size > 200) buffer.removeAt(0)
|
if (buffer.size > MAX_BUFFERED_EVENTS) buffer.removeAt(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun end(screen: String) = synchronized(lock) {
|
fun end(screen: String) = synchronized(lock) {
|
||||||
@@ -77,6 +84,14 @@ class JourneyAnalytics(
|
|||||||
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
|
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
|
||||||
|
|
||||||
companion object {
|
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>() {
|
private val iso8601 = object : ThreadLocal<SimpleDateFormat>() {
|
||||||
override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
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 feature: String = "",
|
||||||
val source: String = "",
|
val source: String = "",
|
||||||
val target: String = "",
|
val target: String = "",
|
||||||
|
val itemId: String = "",
|
||||||
val itemName: String = "",
|
val itemName: String = "",
|
||||||
val itemType: 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 outcome: String = "",
|
||||||
val occurredAt: 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.HomeSnapshot
|
||||||
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||||
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
|
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.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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. */
|
/** Row engagement, buffered here and uploaded in batches. */
|
||||||
private val analytics = RowAnalytics()
|
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
|
@Volatile private var analyticsPausedForPlayback = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -226,9 +232,21 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
|
|
||||||
fun trackJourney(
|
fun trackJourney(
|
||||||
category: String, action: String, screen: String = "", feature: String = "",
|
category: String, action: String, screen: String = "", feature: String = "",
|
||||||
source: String = "", target: String = "", itemName: String = "", itemType: String = "",
|
source: String = "", target: String = "", itemId: String = "", itemName: String = "",
|
||||||
outcome: String = "",
|
itemType: String = "", playSessionId: String = "", outcome: String = "",
|
||||||
) = journey.track(category, action, screen, feature, source, target, itemName, itemType, outcome)
|
) = 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() }
|
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.Settings
|
||||||
import com.ponzischeme89.memby.data.EmbyProfile
|
import com.ponzischeme89.memby.data.EmbyProfile
|
||||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
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.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
@@ -1975,6 +1977,13 @@ private fun HomeScreen(
|
|||||||
var resolvingItem by remember { mutableStateOf<BaseItem?>(null) }
|
var resolvingItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
|
var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var returnItemId 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 recentSearches by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var initialSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
var initialSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
// Destination and row list states live above the conditional content branches.
|
// Destination and row list states live above the conditional content branches.
|
||||||
@@ -2167,12 +2176,37 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
||||||
if (launchingItem != null || !item.membyPlayable) return@playItem
|
if (launchingItem != null || !item.membyPlayable) return@playItem
|
||||||
homeViewModel.trackJourney(
|
// The shelf this press traces back to, resolved once and then carried all the way
|
||||||
category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
|
// into the player, so the step the launcher records and the step the player records
|
||||||
feature = "playback", source = returnRowId.orEmpty(), target = "player",
|
// agree about where the viewer came from. `source` used to be the raw row id, which
|
||||||
itemName = item.name, itemType = item.type,
|
// 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()
|
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
|
launchingItem = item
|
||||||
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
||||||
// Resuming: open the player now and let it resolve the stream while it starts.
|
// 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)
|
val request = repo.playbackRequest(item)
|
||||||
request to repo.readyPlayableForLaunch(request)
|
request to repo.readyPlayableForLaunch(request)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
|
recordPlaybackFailed()
|
||||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||||
launchingItem = null
|
launchingItem = null
|
||||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||||
@@ -2206,10 +2241,12 @@ private fun HomeScreen(
|
|||||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
||||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
?: repo.primaryUrl(item, maxWidth = 1920),
|
||||||
requestStartedAtMs = playbackRequestedAtMs,
|
requestStartedAtMs = playbackRequestedAtMs,
|
||||||
|
journeySource = entryPoint.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (launched.isFailure) {
|
if (launched.isFailure) {
|
||||||
|
recordPlaybackFailed()
|
||||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||||
launchingItem = null
|
launchingItem = null
|
||||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||||
@@ -2273,10 +2310,12 @@ private fun HomeScreen(
|
|||||||
playSessionId = playable.playSessionId,
|
playSessionId = playable.playSessionId,
|
||||||
playMethod = playable.playMethod,
|
playMethod = playable.playMethod,
|
||||||
requestStartedAtMs = playbackRequestedAtMs,
|
requestStartedAtMs = playbackRequestedAtMs,
|
||||||
|
journeySource = entryPoint.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (launched.isFailure) {
|
if (launched.isFailure) {
|
||||||
|
recordPlaybackFailed()
|
||||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||||
launchingItem = null
|
launchingItem = null
|
||||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||||
@@ -2286,6 +2325,7 @@ private fun HomeScreen(
|
|||||||
// A cancellation is the viewer having pressed Back out of the
|
// A cancellation is the viewer having pressed Back out of the
|
||||||
// wait, which has already reopened the gate and said so on screen.
|
// wait, which has already reopened the gate and said so on screen.
|
||||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||||
|
recordPlaybackFailed()
|
||||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||||
// Nothing was launched, so nothing will come back to reopen the gate.
|
// Nothing was launched, so nothing will come back to reopen the gate.
|
||||||
launchingItem = null
|
launchingItem = null
|
||||||
@@ -2535,6 +2575,11 @@ private fun HomeScreen(
|
|||||||
savedFocus?.let { (rowId, itemId) ->
|
savedFocus?.let { (rowId, itemId) ->
|
||||||
returnRowId = rowId
|
returnRowId = rowId
|
||||||
returnItemId = itemId
|
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 (
|
if (
|
||||||
savedFocus != null &&
|
savedFocus != null &&
|
||||||
@@ -2623,6 +2668,7 @@ private fun HomeScreen(
|
|||||||
onItemFocused = homeViewModel::focusItem,
|
onItemFocused = homeViewModel::focusItem,
|
||||||
onItemSelected = { item ->
|
onItemSelected = { item ->
|
||||||
returnRowId = SEARCH_ROW_ID
|
returnRowId = SEARCH_ROW_ID
|
||||||
|
returnRowKind = null
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
|
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
@@ -2705,6 +2751,7 @@ private fun HomeScreen(
|
|||||||
onItemFocused = homeViewModel::focusItem,
|
onItemFocused = homeViewModel::focusItem,
|
||||||
onItemSelected = { item ->
|
onItemSelected = { item ->
|
||||||
returnRowId = GENRE_BROWSER_ROW_ID
|
returnRowId = GENRE_BROWSER_ROW_ID
|
||||||
|
returnRowKind = null
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
destinationFocus[BrowseDestination.GENRES] =
|
destinationFocus[BrowseDestination.GENRES] =
|
||||||
GENRE_BROWSER_ROW_ID to item.id
|
GENRE_BROWSER_ROW_ID to item.id
|
||||||
@@ -2744,6 +2791,7 @@ private fun HomeScreen(
|
|||||||
onItemFocused = homeViewModel::focusItem,
|
onItemFocused = homeViewModel::focusItem,
|
||||||
onItemSelected = { item ->
|
onItemSelected = { item ->
|
||||||
returnRowId = GENRE_BROWSER_ROW_ID
|
returnRowId = GENRE_BROWSER_ROW_ID
|
||||||
|
returnRowKind = null
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
@@ -2885,11 +2933,13 @@ private fun HomeScreen(
|
|||||||
focusedHomeRowId = null
|
focusedHomeRowId = null
|
||||||
destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
|
destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
|
||||||
returnRowId = HOME_HERO_ROW_ID
|
returnRowId = HOME_HERO_ROW_ID
|
||||||
|
returnRowKind = null
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
},
|
},
|
||||||
onItemSelected = { item ->
|
onItemSelected = { item ->
|
||||||
returnRowId = HOME_HERO_ROW_ID
|
returnRowId = HOME_HERO_ROW_ID
|
||||||
|
returnRowKind = null
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
@@ -3148,12 +3198,14 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
destinationFocus[selectedDestination] = row.id to item.id
|
destinationFocus[selectedDestination] = row.id to item.id
|
||||||
returnRowId = row.id
|
returnRowId = row.id
|
||||||
|
returnRowKind = row.kind.name
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
homeViewModel.trackRowFocused(row.id, row.kind.name, item.id)
|
homeViewModel.trackRowFocused(row.id, row.kind.name, item.id)
|
||||||
},
|
},
|
||||||
onItemSelected = { item ->
|
onItemSelected = { item ->
|
||||||
returnRowId = row.id
|
returnRowId = row.id
|
||||||
|
returnRowKind = row.kind.name
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
|
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
@@ -3181,6 +3233,7 @@ private fun HomeScreen(
|
|||||||
},
|
},
|
||||||
onItemLongPressed = { item ->
|
onItemLongPressed = { item ->
|
||||||
returnRowId = row.id
|
returnRowId = row.id
|
||||||
|
returnRowKind = row.kind.name
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
if (item.membyPlayable) {
|
if (item.membyPlayable) {
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ internal class MembyRenderersFactory(
|
|||||||
}
|
}
|
||||||
return builder
|
return builder
|
||||||
.setEnableFloatOutput(enableFloatOutput)
|
.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()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,9 +72,11 @@ internal class MembyRenderersFactory(
|
|||||||
/**
|
/**
|
||||||
* Builds the fixed-capabilities sink used by manual mode.
|
* Builds the fixed-capabilities sink used by manual mode.
|
||||||
*
|
*
|
||||||
* Media3 1.5 ignores [DefaultAudioSink.Builder.setAudioCapabilities] when the builder has
|
* Media3 ignores [DefaultAudioSink.Builder.setAudioCapabilities] when the builder has a
|
||||||
* a Context, because the live route receiver replaces the supplied value. Its deprecated
|
* 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.
|
* 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")
|
@Suppress("DEPRECATION")
|
||||||
private fun fixedCapabilitiesAudioSinkBuilder(
|
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,
|
private val elapsedRealtime: () -> Long,
|
||||||
/** Injected so a test needs no repository, and so the direct/gateway split stays put. */
|
/** Injected so a test needs no repository, and so the direct/gateway split stays put. */
|
||||||
private val fetch: suspend (itemId: String) -> NextEpisode?,
|
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. */
|
/** Guards the whole of [resolve]: two callers must never produce two requests. */
|
||||||
private val lock = Mutex()
|
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
|
// would put up a banner promising something that cannot be played and then, the
|
||||||
// countdown having run out, swap it in automatically and fail.
|
// countdown having run out, swap it in automatically and fail.
|
||||||
?.takeIf { it.url.isNotBlank() }
|
?.takeIf { it.url.isNotBlank() }
|
||||||
lock.withLock {
|
val published = lock.withLock {
|
||||||
if (subjectId == itemId) {
|
val current = subjectId == itemId
|
||||||
|
if (current) {
|
||||||
answer = resolved
|
answer = resolved
|
||||||
resolvedAt = elapsedRealtime()
|
resolvedAt = elapsedRealtime()
|
||||||
}
|
}
|
||||||
inFlight = null
|
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)
|
pending.complete(resolved)
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
package com.ponzischeme89.memby.ui.player
|
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.
|
* 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.audioPassthroughPreference
|
||||||
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
||||||
import com.ponzischeme89.memby.data.IntroSegment
|
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.creditsWorthShowing
|
||||||
import com.ponzischeme89.memby.data.NextEpisode
|
import com.ponzischeme89.memby.data.NextEpisode
|
||||||
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
|
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
|
||||||
@@ -163,6 +166,26 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var playSessionId = ""
|
private var playSessionId = ""
|
||||||
private var playMethod = "DirectPlay"
|
private var playMethod = "DirectPlay"
|
||||||
private var stoppedInBackground = false
|
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 availableSubtitles: List<PlayableSubtitle> = emptyList()
|
||||||
private var encodedSubtitleId: String? = null
|
private var encodedSubtitleId: String? = null
|
||||||
private var remainingView: TextView? = null
|
private var remainingView: TextView? = null
|
||||||
@@ -317,9 +340,25 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
scope = lifecycleScope,
|
scope = lifecycleScope,
|
||||||
elapsedRealtime = SystemClock::elapsedRealtime,
|
elapsedRealtime = SystemClock::elapsedRealtime,
|
||||||
fetch = { id -> ServiceLocator.repository.nextEpisode(id, seriesId = null) },
|
fetch = { id -> ServiceLocator.repository.nextEpisode(id, seriesId = null) },
|
||||||
|
onResolved = ::preloadNextEpisode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
private val nextEpisode: NextEpisode? get() = nextUpResolver.current
|
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 returningHomeAfterCompletion = false
|
||||||
private var nextUpJob: Job? = null
|
private var nextUpJob: Job? = null
|
||||||
private var nextEpisodeLookupJob: Job? = null
|
private var nextEpisodeLookupJob: Job? = null
|
||||||
@@ -527,6 +566,13 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
return
|
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)
|
itemId = savedInstanceState?.getString(STATE_ITEM_ID)
|
||||||
?: intent.getStringExtra(EXTRA_ITEM_ID)
|
?: intent.getStringExtra(EXTRA_ITEM_ID)
|
||||||
mediaSourceId = savedInstanceState?.getString(STATE_MEDIA_SOURCE_ID)
|
mediaSourceId = savedInstanceState?.getString(STATE_MEDIA_SOURCE_ID)
|
||||||
@@ -680,8 +726,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// first one cannot arrive until onCreate has returned.
|
// first one cannot arrive until onCreate has returned.
|
||||||
// Surround passthrough is settled before the sink is built, not after: an audio
|
// 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.
|
// sink cannot change its mind about bitstreaming a format once a track is open.
|
||||||
val createdPlayer = runCatching {
|
val createdSession = runCatching {
|
||||||
PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference)
|
PlayerEngine.createSession(this, ServiceLocator.settings.current.audioPassthroughPreference)
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
Log.e(PLAYBACK_LOG_TAG, "event=player_create_failed", error)
|
Log.e(PLAYBACK_LOG_TAG, "event=player_create_failed", error)
|
||||||
prerollActive = false
|
prerollActive = false
|
||||||
@@ -700,7 +746,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
player = createdPlayer.also { playback ->
|
preloader = createdSession.preloader
|
||||||
|
player = createdSession.player.also { playback ->
|
||||||
view.player = playback
|
view.player = playback
|
||||||
trace.mark(PlaybackTrace.PLAYER_BUILT)
|
trace.mark(PlaybackTrace.PLAYER_BUILT)
|
||||||
// Audio start is not on Player.Listener — only the analytics interface
|
// Audio start is not on Player.Listener — only the analytics interface
|
||||||
@@ -842,6 +889,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
Log.i(
|
Log.i(
|
||||||
PLAYBACK_LOG_TAG,
|
PLAYBACK_LOG_TAG,
|
||||||
"event=first_frame item=${itemId.orEmpty()} totalMs=$firstFrameMs " +
|
"event=first_frame item=${itemId.orEmpty()} totalMs=$firstFrameMs " +
|
||||||
|
"start=${if (startedFromPreloadedSource) "preloaded" else "cold"} " +
|
||||||
"resumeMs=$initialResumePositionMs ${trace.summary()}",
|
"resumeMs=$initialResumePositionMs ${trace.summary()}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -922,13 +970,25 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val playback = player ?: return
|
val playback = player ?: return
|
||||||
currentMediaUrl = url
|
currentMediaUrl = url
|
||||||
currentMediaSubtitles = subtitles
|
currentMediaSubtitles = subtitles
|
||||||
|
startedFromPreloadedSource = false
|
||||||
val prepared = runCatching {
|
val prepared = runCatching {
|
||||||
require(url.isNotBlank()) { "Playback URL is blank" }
|
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
|
// 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.
|
// in-process equivalent of the state reset an app restart used to provide.
|
||||||
playback.stop()
|
playback.stop()
|
||||||
playback.clearMediaItems()
|
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.playWhenReady = playWhenReady
|
||||||
playback.prepare()
|
playback.prepare()
|
||||||
}
|
}
|
||||||
@@ -1549,6 +1609,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private fun startPlaybackSession(playback: Player) {
|
private fun startPlaybackSession(playback: Player) {
|
||||||
if (playbackStarted) return
|
if (playbackStarted) return
|
||||||
playbackStarted = true
|
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()
|
if (!prerollActive) showPlaybackIdentity()
|
||||||
// Deliberately here rather than in onCreate. Nothing needs the cast until the
|
// 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
|
// 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) {
|
private fun showPlaybackError(failure: PlaybackFailure) {
|
||||||
|
recordPlaybackFailedJourney()
|
||||||
hidePlaybackLoading()
|
hidePlaybackLoading()
|
||||||
playerView?.hideController()
|
playerView?.hideController()
|
||||||
errorTitleView?.text = failure.title
|
errorTitleView?.text = failure.title
|
||||||
@@ -3058,6 +3124,67 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private val playingAnEpisode: Boolean
|
private val playingAnEpisode: Boolean
|
||||||
get() = !playbackSeriesName.isNullOrBlank() || prerollEpisodeCode.isNotBlank()
|
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.
|
* Puts the two optional transport controls in step with what is actually known.
|
||||||
*
|
*
|
||||||
@@ -3091,6 +3218,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private fun playSomethingElse() {
|
private fun playSomethingElse() {
|
||||||
if (magicJob?.isActive == true || advanceRequested || advancing) return
|
if (magicJob?.isActive == true || advanceRequested || advancing) return
|
||||||
val current = itemId?.takeIf { it.isNotBlank() }
|
val current = itemId?.takeIf { it.isNotBlank() }
|
||||||
|
PlaybackJourney.magicRequested(JourneyTracker)
|
||||||
magicJob = lifecycleScope.launch {
|
magicJob = lifecycleScope.launch {
|
||||||
showPlaybackLoading(
|
showPlaybackLoading(
|
||||||
title = getString(R.string.player_magic_finding),
|
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
|
// 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
|
// 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.
|
// over somebody's film: say so, put the picture back and withdraw the button.
|
||||||
|
PlaybackJourney.magicEmpty(JourneyTracker)
|
||||||
magicAvailable = false
|
magicAvailable = false
|
||||||
updateNextEpisodeButton()
|
updateNextEpisodeButton()
|
||||||
hidePlaybackLoading()
|
hidePlaybackLoading()
|
||||||
@@ -3116,6 +3245,26 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
magicOffered += pick.itemId
|
magicOffered += pick.itemId
|
||||||
if (magicOffered.size > MAGIC_MEMORY) magicOffered.removeAt(0)
|
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(
|
Toast.makeText(
|
||||||
this@PlayerActivity,
|
this@PlayerActivity,
|
||||||
getString(R.string.player_magic_selected, pick.title),
|
getString(R.string.player_magic_selected, pick.title),
|
||||||
@@ -3142,6 +3291,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
logoUrl = pick.logoUrl,
|
logoUrl = pick.logoUrl,
|
||||||
),
|
),
|
||||||
backdropUrl = pick.backdropUrl,
|
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) {
|
private fun playNext(next: NextEpisode) {
|
||||||
if (advancing || returningHomeAfterCompletion) return
|
if (advancing || returningHomeAfterCompletion) return
|
||||||
advancing = true
|
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
|
itemId = next.itemId
|
||||||
mediaSourceId = next.mediaSourceId
|
mediaSourceId = next.mediaSourceId
|
||||||
playSessionId = next.playSessionId
|
playSessionId = next.playSessionId
|
||||||
@@ -3909,6 +4087,19 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
encodedSubtitleId = null
|
encodedSubtitleId = null
|
||||||
stopReported = false
|
stopReported = false
|
||||||
playbackStarted = 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
|
playbackIdentityShown = false
|
||||||
playbackIdentityHideJob?.cancel()
|
playbackIdentityHideJob?.cancel()
|
||||||
playbackIdentityView?.apply {
|
playbackIdentityView?.apply {
|
||||||
@@ -3973,6 +4164,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
if (playback != null) {
|
if (playback != null) {
|
||||||
trace.mark(PlaybackTrace.STREAM_RESOLVED)
|
trace.mark(PlaybackTrace.STREAM_RESOLVED)
|
||||||
startMedia(next.url, next.subtitles, next.resumePositionMs, playWhenReady = true)
|
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
|
advancing = false
|
||||||
}
|
}
|
||||||
@@ -3986,6 +4182,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private fun interruptForMaintenance() {
|
private fun interruptForMaintenance() {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
progressJob?.cancel()
|
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(
|
startActivity(
|
||||||
Intent(this, MainActivity::class.java).addFlags(
|
Intent(this, MainActivity::class.java).addFlags(
|
||||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
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_MEDIA_SOURCE_ID, mediaSourceId)
|
||||||
outState.putString(STATE_PLAY_SESSION_ID, playSessionId)
|
outState.putString(STATE_PLAY_SESSION_ID, playSessionId)
|
||||||
outState.putString(STATE_PLAY_METHOD, playMethod)
|
outState.putString(STATE_PLAY_METHOD, playMethod)
|
||||||
|
outState.putString(STATE_JOURNEY_SOURCE, journeyEntryPoint.id)
|
||||||
val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles
|
val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles
|
||||||
if (savedSubtitles.isNotEmpty()) {
|
if (savedSubtitles.isNotEmpty()) {
|
||||||
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles))
|
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles))
|
||||||
@@ -5128,6 +5330,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
playerView?.player = null
|
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()
|
playback?.release()
|
||||||
player = null
|
player = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -5312,6 +5519,13 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
|
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_SESSION_ID = "extra_play_session_id"
|
||||||
private const val EXTRA_PLAY_METHOD = "extra_play_method"
|
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_PLAYBACK_REQUEST = "extra_playback_request"
|
||||||
private const val EXTRA_TRAILER_REQUEST = "extra_trailer_request"
|
private const val EXTRA_TRAILER_REQUEST = "extra_trailer_request"
|
||||||
private const val EXTRA_TRAILER_UNAVAILABLE = "extra_trailer_unavailable"
|
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_RUNTIME_MS = "state_runtime_ms"
|
||||||
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
|
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
|
||||||
private const val STATE_MAGIC_OFFERED = "state_magic_offered"
|
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 PLAYER_PREFERENCES = "player_preferences"
|
||||||
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
|
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
|
||||||
private const val PICTURE_MODE_KEY = "picture_mode"
|
private const val PICTURE_MODE_KEY = "picture_mode"
|
||||||
@@ -5365,8 +5580,10 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
posterUrl: String? = null,
|
posterUrl: String? = null,
|
||||||
backdropUrl: String? = null,
|
backdropUrl: String? = null,
|
||||||
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
||||||
|
journeySource: String = PlaybackEntryPoint.UNKNOWN.id,
|
||||||
): Intent = Intent(context, PlayerActivity::class.java).apply {
|
): Intent = Intent(context, PlayerActivity::class.java).apply {
|
||||||
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
|
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
|
||||||
|
putExtra(EXTRA_JOURNEY_SOURCE, journeySource)
|
||||||
putExtra(EXTRA_ITEM_ID, request.itemId)
|
putExtra(EXTRA_ITEM_ID, request.itemId)
|
||||||
putExtra(EXTRA_TITLE, request.title)
|
putExtra(EXTRA_TITLE, request.title)
|
||||||
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
|
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
|
||||||
@@ -5428,8 +5645,10 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
playSessionId: String = "",
|
playSessionId: String = "",
|
||||||
playMethod: String = "DirectPlay",
|
playMethod: String = "DirectPlay",
|
||||||
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
||||||
|
journeySource: String = PlaybackEntryPoint.UNKNOWN.id,
|
||||||
): Intent =
|
): Intent =
|
||||||
Intent(context, PlayerActivity::class.java).apply {
|
Intent(context, PlayerActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_JOURNEY_SOURCE, journeySource)
|
||||||
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
|
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
|
||||||
putExtra(EXTRA_URL, url)
|
putExtra(EXTRA_URL, url)
|
||||||
putExtra(EXTRA_TITLE, title)
|
putExtra(EXTRA_TITLE, title)
|
||||||
@@ -5619,7 +5838,6 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
|
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
|
||||||
private const val STABLE_PLAYBACK_RESET_MS = 30_000L
|
private const val STABLE_PLAYBACK_RESET_MS = 30_000L
|
||||||
private const val PROLONGED_REBUFFER_RECOVERY_MS = 12_000L
|
private const val PROLONGED_REBUFFER_RECOVERY_MS = 12_000L
|
||||||
private const val PLAYBACK_LOG_TAG = "MembyPlayback"
|
|
||||||
|
|
||||||
private fun playbackStateName(state: Int): String =
|
private fun playbackStateName(state: Int): String =
|
||||||
when (state) {
|
when (state) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ponzischeme89.memby.ui.player
|
package com.ponzischeme89.memby.ui.player
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -10,7 +11,9 @@ import androidx.media3.exoplayer.DefaultLoadControl
|
|||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.source.preload.DefaultPreloadManager
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import androidx.media3.exoplayer.trackselection.TrackSelector
|
||||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
||||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||||
@@ -33,6 +36,30 @@ import java.util.concurrent.TimeUnit
|
|||||||
@UnstableApi
|
@UnstableApi
|
||||||
internal object PlayerEngine {
|
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
|
* [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
|
* here into PCM. It defaults to automatic, which is also the right answer for the
|
||||||
@@ -41,43 +68,102 @@ internal object PlayerEngine {
|
|||||||
fun create(
|
fun create(
|
||||||
context: Context,
|
context: Context,
|
||||||
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
|
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
|
||||||
): ExoPlayer = try {
|
): ExoPlayer = createSession(context, audio, preloading = false).player
|
||||||
buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
} catch (_: RuntimeException) {
|
||||||
// Extension construction happens while ExoPlayer is built. A binary mismatch or
|
// Extension construction happens while ExoPlayer is built. A binary mismatch or
|
||||||
// broken vendor audio implementation must degrade to Android's renderers rather
|
// broken vendor audio implementation must degrade to Android's renderers rather
|
||||||
// than make Memby close during its process-wide pre-roll warm-up.
|
// 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) {
|
} 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,
|
context: Context,
|
||||||
audio: AudioPassthroughPreference,
|
audio: AudioPassthroughPreference,
|
||||||
extensionRendererMode: Int,
|
extensionRendererMode: Int,
|
||||||
): ExoPlayer = ExoPlayer.Builder(context)
|
preloading: Boolean,
|
||||||
.setMediaSourceFactory(mediaSourceFactory(context))
|
): Session {
|
||||||
.setRenderersFactory(
|
val preloader = NextEpisodePreloader()
|
||||||
MembyRenderersFactory(context, audio)
|
val renderers = renderersFactory(context, audio, extensionRendererMode)
|
||||||
// Some Android TV firmwares advertise a preferred hardware decoder which
|
val sources = mediaSourceFactory(context)
|
||||||
// fails only after initialization. Let Media3 try another installed decoder
|
// Everything the two share is set on the *preload manager's* builder, not on the
|
||||||
// before declaring the file unsupported.
|
// ExoPlayer one: buildExoPlayer overwrites the media source factory, renderers, load
|
||||||
.setEnableDecoderFallback(true)
|
// control, bandwidth meter, track selector and playback looper on whatever builder it
|
||||||
// The bundled FFmpeg audio renderer sits after platform decoders. It is
|
// is handed. Setting them twice would be harmless but misleading — the ExoPlayer
|
||||||
// reached only when Android cannot decode a surround format itself; its
|
// builder below carries only what the manager has no opinion about.
|
||||||
// output is PCM, so passthrough-capable tracks still bypass it untouched.
|
val player = if (preloading) {
|
||||||
.setExtensionRendererMode(extensionRendererMode),
|
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))
|
return Session(player, preloader)
|
||||||
.setLoadControl(loadControl())
|
}
|
||||||
.build()
|
|
||||||
.apply {
|
/**
|
||||||
setAudioAttributes(
|
* Carries only what [DefaultPreloadManager.Builder.buildExoPlayer] does not overwrite.
|
||||||
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
*
|
||||||
/* handleAudioFocus = */ false,
|
* `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(
|
private fun mediaSourceFactory(context: Context) = DefaultMediaSourceFactory(
|
||||||
// DefaultDataSource still handles the non-HTTP schemes (file:, asset:, content:);
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// of what the gateway does in the background rather than as more wiring in here.
|
||||||
server.RegisterHousekeeping(sched)
|
server.RegisterHousekeeping(sched)
|
||||||
server.RegisterCreditsTasks(sched)
|
server.RegisterCreditsTasks(sched)
|
||||||
|
server.RegisterWatchTimeTasks(sched)
|
||||||
sched.Start(ctx)
|
sched.Start(ctx)
|
||||||
|
|
||||||
// Installed after the server exists, because both halves of a finished import are
|
// Installed after the server exists, because both halves of a finished import are
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ type adminMembyAccount struct {
|
|||||||
// themeAllowed take. The console renders that as every box ticked, which is what an
|
// themeAllowed take. The console renders that as every box ticked, which is what an
|
||||||
// operator who has never touched the page should see.
|
// operator who has never touched the page should see.
|
||||||
Themes []string `json:"themes"`
|
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 {
|
type adminAccountSettings struct {
|
||||||
@@ -108,6 +112,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
|||||||
notifications = map[string]store.NotificationPreferences{}
|
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))
|
result := make([]adminMembyAccount, 0, len(accounts))
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
pref := preferences[account.ID]
|
pref := preferences[account.ID]
|
||||||
@@ -137,8 +149,10 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !savedNotifications {
|
if !savedNotifications {
|
||||||
notificationPrefs = store.DefaultNotificationPreferences()
|
notificationPrefs = store.DefaultNotificationPreferences()
|
||||||
}
|
}
|
||||||
|
watched, matchedWatchTime := watchTime[account.ID]
|
||||||
result = append(result, adminMembyAccount{
|
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"),
|
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
||||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||||
Themes: nonNilStrings(themes[account.ID]),
|
Themes: nonNilStrings(themes[account.ID]),
|
||||||
|
|||||||
@@ -43,10 +43,15 @@ type journeyEventPayload struct {
|
|||||||
Feature string `json:"feature"`
|
Feature string `json:"feature"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
|
ItemID string `json:"itemId"`
|
||||||
ItemName string `json:"itemName"`
|
ItemName string `json:"itemName"`
|
||||||
ItemType string `json:"itemType"`
|
ItemType string `json:"itemType"`
|
||||||
Outcome string `json:"outcome"`
|
// The Emby play session a playback step belongs to. Validated like every other
|
||||||
OccurredAt string `json:"occurredAt"`
|
// 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 {
|
type journeyAnalyticsRequest struct {
|
||||||
@@ -100,7 +105,8 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
|
|||||||
!journeyOutcomes[payload.Outcome] {
|
!journeyOutcomes[payload.Outcome] {
|
||||||
return store.JourneyEvent{}, false
|
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 {
|
for _, field := range fields {
|
||||||
if !safeAnalyticsValue(field, 100) {
|
if !safeAnalyticsValue(field, 100) {
|
||||||
return store.JourneyEvent{}, false
|
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,
|
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
|
||||||
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
|
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
|
||||||
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
||||||
Target: payload.Target, ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
|
Target: payload.Target, ItemID: payload.ItemID,
|
||||||
Outcome: payload.Outcome}, true
|
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
|
||||||
|
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeAnalyticsValue(value string, max int) bool {
|
func safeAnalyticsValue(value string, max int) bool {
|
||||||
|
|||||||
@@ -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) {
|
func TestJourneyEventRetainsTheItemName(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
|
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
featureSeasonalDecorations = "seasonal_decorations"
|
featureSeasonalDecorations = "seasonal_decorations"
|
||||||
featureGenreBrowser = "genre_browser"
|
featureGenreBrowser = "genre_browser"
|
||||||
featureTVCalendar = "tv_calendar"
|
featureTVCalendar = "tv_calendar"
|
||||||
|
featureWatchTimeDigest = "watch_time_digest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type featureDefinition struct {
|
type featureDefinition struct {
|
||||||
@@ -133,6 +134,18 @@ var featureCatalogue = []featureDefinition{
|
|||||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
|
||||||
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
|
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",
|
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 " +
|
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 {
|
if !prefs.Enabled {
|
||||||
return []store.UserNotification{}
|
return []store.UserNotification{}
|
||||||
}
|
}
|
||||||
if prefs.SonarrAlerts {
|
if prefs.SonarrAlerts && prefs.WatchTimeDigest {
|
||||||
return notifications
|
return notifications
|
||||||
}
|
}
|
||||||
filtered := make([]store.UserNotification, 0, len(notifications))
|
filtered := make([]store.UserNotification, 0, len(notifications))
|
||||||
for _, notification := range notifications {
|
for _, notification := range notifications {
|
||||||
switch notification.Kind {
|
switch notification.Kind {
|
||||||
case "show-return", "show-added", "show-cancelled", "auto-follow":
|
case "show-return", "show-added", "show-cancelled", "auto-follow":
|
||||||
continue
|
if !prefs.SonarrAlerts {
|
||||||
default:
|
continue
|
||||||
filtered = append(filtered, notification)
|
}
|
||||||
|
// 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
|
return filtered
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
0.1.52
|
0.1.53
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,15 @@ type JourneyEvent struct {
|
|||||||
Feature string `json:"feature"`
|
Feature string `json:"feature"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
|
ItemID string `json:"itemId,omitempty"`
|
||||||
ItemName string `json:"itemName,omitempty"`
|
ItemName string `json:"itemName,omitempty"`
|
||||||
ItemType string `json:"itemType,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 {
|
type AnalyticsUser struct {
|
||||||
@@ -306,12 +312,13 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
|
|||||||
batch.Queue(`
|
batch.Queue(`
|
||||||
INSERT INTO journey_events
|
INSERT INTO journey_events
|
||||||
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
|
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
|
||||||
feature, source, target, item_name, item_type, outcome)
|
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)
|
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`,
|
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
|
||||||
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
|
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
|
||||||
event.Category, event.Action, event.Screen, event.Feature, event.Source,
|
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)
|
results := s.pool.SendBatch(ctx, batch)
|
||||||
defer results.Close()
|
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) {
|
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
|
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
|
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
|
||||||
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
|
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -474,7 +482,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
|
|||||||
out := []JourneyEvent{}
|
out := []JourneyEvent{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v JourneyEvent
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
|
|||||||
@@ -23,13 +23,19 @@ type NotificationPreferences struct {
|
|||||||
UpdateAlerts bool `json:"updateAlerts"`
|
UpdateAlerts bool `json:"updateAlerts"`
|
||||||
LibraryAlerts bool `json:"libraryAlerts"`
|
LibraryAlerts bool `json:"libraryAlerts"`
|
||||||
SystemAlerts bool `json:"systemAlerts"`
|
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 {
|
func DefaultNotificationPreferences() NotificationPreferences {
|
||||||
return NotificationPreferences{
|
return NotificationPreferences{
|
||||||
Enabled: true, ShowReturnAlerts: true, SonarrAlerts: true, RadarrAlerts: true,
|
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()
|
prefs := DefaultNotificationPreferences()
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
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).
|
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
|
||||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts, &prefs.RadarrAlerts,
|
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) {
|
if err != nil && !isNoRows(err) {
|
||||||
return prefs, fmt.Errorf("store: notification preferences: %w", err)
|
return prefs, fmt.Errorf("store: notification preferences: %w", err)
|
||||||
}
|
}
|
||||||
@@ -232,8 +239,8 @@ func (s *Store) SetNotificationPreferences(
|
|||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
INSERT INTO user_notification_preferences
|
INSERT INTO user_notification_preferences
|
||||||
(emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
(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)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||||
enabled = EXCLUDED.enabled,
|
enabled = EXCLUDED.enabled,
|
||||||
show_return_alerts = EXCLUDED.show_return_alerts,
|
show_return_alerts = EXCLUDED.show_return_alerts,
|
||||||
@@ -242,17 +249,19 @@ func (s *Store) SetNotificationPreferences(
|
|||||||
update_alerts = EXCLUDED.update_alerts,
|
update_alerts = EXCLUDED.update_alerts,
|
||||||
library_alerts = EXCLUDED.library_alerts,
|
library_alerts = EXCLUDED.library_alerts,
|
||||||
system_alerts = EXCLUDED.system_alerts,
|
system_alerts = EXCLUDED.system_alerts,
|
||||||
|
watch_time_digest = EXCLUDED.watch_time_digest,
|
||||||
lead_days = EXCLUDED.lead_days,
|
lead_days = EXCLUDED.lead_days,
|
||||||
updated_at = now()`,
|
updated_at = now()`,
|
||||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.SonarrAlerts, prefs.RadarrAlerts,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
|
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
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`)
|
FROM user_notification_preferences`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list notification preferences: %w", err)
|
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
|
var userID string
|
||||||
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
|
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
|
||||||
&prefs.RadarrAlerts, &prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
|
&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)
|
return nil, fmt.Errorf("store: scan notification preferences: %w", err)
|
||||||
}
|
}
|
||||||
result[userID] = prefs
|
result[userID] = prefs
|
||||||
|
|||||||
@@ -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 '';
|
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
|
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
|
||||||
ON journey_events (emby_user_id, journey_id, sequence);
|
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,
|
update_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||||
library_alerts BOOLEAN NOT NULL DEFAULT true,
|
library_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||||
system_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),
|
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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 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 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 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.
|
-- 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
|
-- source_key is deterministic, preventing the same return date from being announced
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user