feat(playevents): #426B server — RecordOfflinePlay + /api/events play_offline
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>
This commit is contained in:
@@ -65,6 +65,8 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleEventPlayEnded(w, r, user, req, at)
|
||||
case "play_skipped":
|
||||
h.handleEventPlaySkipped(w, r, user, req, at)
|
||||
case "play_offline":
|
||||
h.handleEventPlayOffline(w, r, user, req, at, clientID)
|
||||
default:
|
||||
writeErr(w, apierror.BadRequest("bad_request", "unknown event type"))
|
||||
}
|
||||
@@ -104,6 +106,48 @@ func (h *handlers) handleEventPlayStarted(
|
||||
})
|
||||
}
|
||||
|
||||
// handleEventPlayOffline records a complete play that happened with
|
||||
// no/flaky connectivity, replayed from the Flutter offline mutation
|
||||
// queue (#426 part B). `at` is the original (client) play-start time
|
||||
// — parsed by handleEvents from req.At — so history lands on the
|
||||
// real timeline, not replay time. duration_played_ms drives the skip
|
||||
// rule; source advances #415 rotation for system-playlist plays.
|
||||
func (h *handlers) handleEventPlayOffline(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
user dbq.User, req eventRequest, at time.Time, clientID string,
|
||||
) {
|
||||
trackID, ok := parseUUID(req.TrackID)
|
||||
if !ok {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
|
||||
return
|
||||
}
|
||||
if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "duration_played_ms required and must be >= 0"))
|
||||
return
|
||||
}
|
||||
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: events: offline lookup track", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
source := ""
|
||||
if req.Source != nil {
|
||||
source = *req.Source
|
||||
}
|
||||
if err := h.events.RecordOfflinePlay(
|
||||
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
||||
); err != nil {
|
||||
h.logger.Error("api: events: play_offline", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (h *handlers) handleEventPlayEnded(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
user dbq.User, req eventRequest, at time.Time,
|
||||
|
||||
Reference in New Issue
Block a user