Confirmed against prod: exactly one track (17 plays, cold since May 21)
met the c>=5 + 30d-cold bar, and three process defects turned that into
a 1-track playlist instead of the locked placeholder.
- ListRediscoverTracks: collapse the two-tier UNION into one blended
pool. The old shallow-tier gate (WHERE NOT EXISTS deep) was
all-or-nothing — one 6-month row suppressed the entire 30-day tier —
and deep was a strict subset of shallow anyway. Eligibility drops to
>=3 non-skip plays (on a weeks-old history the >=5-play tracks are
precisely the ones still in rotation); ordering prefers >=6mo cold,
then >=5 plays, then raw count.
- Minimum viable mix floor for all five discovery mixes: below
minLen (15; 5 for the album-coherent NewForYou/FirstListens) the
variant is withheld so Home renders the 'listen more to unlock'
placeholder instead of a mix that reads as built-wrong.
- /api/events: clamp client-supplied 'at' to [user.created_at,
now+5m]. Unbounded client clocks could write arbitrarily old plays
and poison the 6-month ordering (prod data verified clean — no
scrub needed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
Server half of the offline-replay capture. New writer path
RecordOfflinePlay: writes a complete start+end play in one txn from
a caller-supplied `at` + duration_played_ms, applying the spec §6
skip rule (same AND-of-thresholds as RecordPlayEnded) and threading
`source` so #415 rotation advances for system-playlist plays just
like the live path. Generalizes RecordSyntheticCompletedPlay (which
hard-codes full completion). duration clamped to [0, track len].
New /api/events type "play_offline" → handleEventPlayOffline:
validates track + duration, reuses the existing req.At parse so the
play lands on the original timeline, not replay time. Subsonic
shim + live 3-call lifecycle untouched.
Flutter half next: EventsApi.playOffline, a play.offline
MutationQueue kind, and PlayEventsReporter capturing the completed
play + enqueuing it when the live calls have no server id / fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of three stages for system-playlist sample-history dedup
(Fable #415, server-side rotation per the operator's choice).
Schema (migration 0027):
- play_events.source (nullable text): which surface a play came
from. 'for_you' / 'discover' feed rotation; NULL for library /
user-playlist / radio / Subsonic.
- system_playlist_rotation_state(user_id, playlist_kind,
played_track_ids uuid[], rotation_started_at, updated_at): the
per-(user,kind) set of already-heard tracks this rotation.
Ingest:
- New RecordPlayStartedWithSource on the writer; RecordPlayStarted
is now a thin source="" wrapper so the frozen Subsonic shim is
untouched (no signature ripple).
- When source is a known system kind, the same txn appends the
track to rotation state (AppendRotationPlayed keeps the array a
set via the conflict CASE).
- /api/events play_started accepts an optional "source".
No serve-behavior change yet — Stage 2 makes shuffle prefer the
unplayed tail + resets on exhaustion; Stage 3 wires the clients to
send source and consume the rotation-aware order.
Tests: rotation appends + dedupes for a system source; source-less
play writes no rotation row. (Existing RecordPlayStarted tests are
unchanged — same wrapper signature, identical behavior at source="".)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discriminated-union JSON over three event types: play_started,
play_ended, play_skipped. Auth via existing RequireUser. play_started
returns play_event_id + session_id; the other two return { ok: true }.
Validates ownership: play_ended/play_skipped on another user's row
returns 403. Mount signature now takes EventsConfig and constructs
the playevents.Writer; server.New propagates it through.