Fixes listening History being empty/sparse on the native Android client.
Root cause (client)
Every native /api/events request omitted the type discriminator. The app's
Json is configured encodeDefaults=false, so a type left at its data-class
default (play_started, etc.) is never serialized. The server multiplexes on type and returns 400 "unknown event type"; PlayEventsReporter swallows
the error and the play_started path has no offline fallback — so the play is
lost without a trace. Confirmed live in the server access log: POST /api/events → 400 on every play while reads return 200.
Net effect: all native Android play events have 400'd since the code was
written. History only ever populated from Flutter/web; as usage moved to the
native app, History went sparse (the 05-31 → 06-01 cliff).
653ed95f forces the discriminator onto the wire with @EncodeDefault(Mode.ALWAYS) on each event request's type.
Server fix (history recovery)
b2c6f6f0 — autoClosePriorOpen previously hardcoded was_skipped=true when
closing an orphaned play, hiding even fully-listened tracks from History. It
now applies the same skip rule as RecordPlayEnded to the duration-capped
elapsed estimate, so a track that played long enough is recovered into History
instead of force-hidden. No skip_events row written (auto-close is an
ambiguous signal, not a deliberate skip).
Follow-ups (not in this PR)
One-time backfill of existing orphans wrongly marked skipped.
Reporter hardening: offline fallback for a failed play_started, onStop
close-by-id (avoid orphan + duplicate), async-start race.
Fixes listening History being empty/sparse on the native Android client.
## Root cause (client)
Every native `/api/events` request omitted the `type` discriminator. The app's
Json is configured `encodeDefaults=false`, so a `type` left at its data-class
default (`play_started`, etc.) is never serialized. The server multiplexes on
`type` and returns **400 "unknown event type"**; `PlayEventsReporter` swallows
the error and the `play_started` path has no offline fallback — so the play is
lost without a trace. Confirmed live in the server access log: `POST
/api/events → 400` on every play while reads return 200.
Net effect: **all** native Android play events have 400'd since the code was
written. History only ever populated from Flutter/web; as usage moved to the
native app, History went sparse (the 05-31 → 06-01 cliff).
`653ed95f` forces the discriminator onto the wire with
`@EncodeDefault(Mode.ALWAYS)` on each event request's `type`.
## Server fix (history recovery)
`b2c6f6f0` — `autoClosePriorOpen` previously hardcoded `was_skipped=true` when
closing an orphaned play, hiding even fully-listened tracks from History. It
now applies the same skip rule as `RecordPlayEnded` to the duration-capped
elapsed estimate, so a track that played long enough is recovered into History
instead of force-hidden. No `skip_events` row written (auto-close is an
ambiguous signal, not a deliberate skip).
## Follow-ups (not in this PR)
- One-time backfill of existing orphans wrongly marked skipped.
- Reporter hardening: offline fallback for a failed `play_started`, `onStop`
close-by-id (avoid orphan + duplicate), async-start race.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
autoClosePriorOpen hardcoded was_skipped=true for every orphaned
play_event (a play_started whose play_ended never arrived, e.g. the
client backgrounded mid-track). That hid fully-listened tracks from
History — a play that sat open past its own length was capped to the
track duration (ratio ~1) yet still flagged skipped. Observed live:
History showed 3 plays for a day of listening because most rows were
auto-closed orphans marked skipped.
Now the auto-close applies the same skip rule as RecordPlayEnded to the
duration-capped elapsed estimate: ratio >= threshold OR elapsed >= the
duration floor -> a real play that lands in History; a genuine
quick-abandon still classifies as a skip. Still writes no skip_events
row, so the ambiguous auto-close never feeds the skip-ratio /
recommendation signal.
This is the server half. The client-side root cause (backgrounded
track transitions never closed, orphaning the rows in the first place)
is tracked separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native client's /api/events requests omitted the `type`
discriminator entirely. The app's Json is configured with
encodeDefaults=false (AppModule), so a `type` left at its data-class
default ("play_started" etc.) is never written to the wire. The server
multiplexes on `type` and returns 400 "unknown event type" for an
empty one, which PlayEventsReporter's catch swallows — and the
play_started path has no offline fallback, so the play is lost with no
trace.
Net effect: EVERY native Android play event (started/ended/skipped/
offline) has 400'd since this code was written. Listening History only
ever populated from the Flutter/web clients; as usage moved to the
native app, History went sparse. Confirmed live in the server access
log: POST /api/events -> 400 on every play, while reads 200.
Force the discriminator onto the wire with
@EncodeDefault(Mode.ALWAYS) on each request type's `type` field.
Surgical (vs flipping encodeDefaults globally), and idiomatic for a
constant-valued discriminator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes listening History being empty/sparse on the native Android client.
Root cause (client)
Every native
/api/eventsrequest omitted thetypediscriminator. The app'sJson is configured
encodeDefaults=false, so atypeleft at its data-classdefault (
play_started, etc.) is never serialized. The server multiplexes ontypeand returns 400 "unknown event type";PlayEventsReporterswallowsthe error and the
play_startedpath has no offline fallback — so the play islost without a trace. Confirmed live in the server access log:
POST /api/events → 400on every play while reads return 200.Net effect: all native Android play events have 400'd since the code was
written. History only ever populated from Flutter/web; as usage moved to the
native app, History went sparse (the 05-31 → 06-01 cliff).
653ed95fforces the discriminator onto the wire with@EncodeDefault(Mode.ALWAYS)on each event request'stype.Server fix (history recovery)
b2c6f6f0—autoClosePriorOpenpreviously hardcodedwas_skipped=truewhenclosing an orphaned play, hiding even fully-listened tracks from History. It
now applies the same skip rule as
RecordPlayEndedto the duration-cappedelapsed estimate, so a track that played long enough is recovered into History
instead of force-hidden. No
skip_eventsrow written (auto-close is anambiguous signal, not a deliberate skip).
Follow-ups (not in this PR)
play_started,onStopclose-by-id (avoid orphan + duplicate), async-start race.
🤖 Generated with Claude Code
typeso plays aren't droppedThe native client's /api/events requests omitted the `type` discriminator entirely. The app's Json is configured with encodeDefaults=false (AppModule), so a `type` left at its data-class default ("play_started" etc.) is never written to the wire. The server multiplexes on `type` and returns 400 "unknown event type" for an empty one, which PlayEventsReporter's catch swallows — and the play_started path has no offline fallback, so the play is lost with no trace. Net effect: EVERY native Android play event (started/ended/skipped/ offline) has 400'd since this code was written. Listening History only ever populated from the Flutter/web clients; as usage moved to the native app, History went sparse. Confirmed live in the server access log: POST /api/events -> 400 on every play, while reads 200. Force the discriminator onto the wire with @EncodeDefault(Mode.ALWAYS) on each request type's `type` field. Surgical (vs flipping encodeDefaults globally), and idiomatic for a constant-valued discriminator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>