package jellyfin import ( "encoding/json" "errors" "io" "net/http" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" "github.com/mithral/vellum/internal/progress" ) // playReport is the body of the three playback-reporting endpoints. Jellyfin // spells these fields in PascalCase and sends PositionTicks in 100ns units. type playReport struct { ItemId string `json:"ItemId"` PositionTicks int64 `json:"PositionTicks"` PlaySessionId string `json:"PlaySessionId"` MediaSourceId string `json:"MediaSourceId"` IsPaused bool `json:"IsPaused"` } // handlePlaying is POST /Sessions/Playing: the client has started (or resumed) // an item. func (h *Handlers) handlePlaying(w http.ResponseWriter, r *http.Request, u auth.User) { h.writePlaybackProgress(w, r, u) } // handlePlayingProgress is POST /Sessions/Playing/Progress: periodic position // updates, which are what keep the resume point current. func (h *Handlers) handlePlayingProgress(w http.ResponseWriter, r *http.Request, u auth.User) { h.writePlaybackProgress(w, r, u) } // handlePlayingStopped is POST /Sessions/Playing/Stopped. The final position is // written the same way; progress.Put decides whether it counts as finished. func (h *Handlers) handlePlayingStopped(w http.ResponseWriter, r *http.Request, u auth.User) { h.writePlaybackProgress(w, r, u) } // writePlaybackProgress turns a Jellyfin report into a progress row. This is // how a Jellyfin client reports resume position, so it is written through the // existing progress store rather than kept anywhere new. func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request, u auth.User) { var rep playReport // Not httpx.DecodeJSON: real clients add fields we do not model, and a // rejected body would lose the user's position. if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&rep); err != nil { if errors.Is(err, io.EOF) { httpx.WriteError(w, http.StatusBadRequest, "request body is empty") return } httpx.WriteError(w, http.StatusBadRequest, "invalid request body") return } id, isLib, ok := decodeID(rep.ItemId) if !ok || isLib { httpx.WriteError(w, http.StatusBadRequest, "invalid ItemId") return } it, ok := h.authorizedItem(w, r, u, id) if !ok { return } // Duration comes from the item's first file, the same source the browse // DTOs use, so PlayedPercentage is consistent across endpoints. var durationMS int64 if err := h.db.QueryRowContext(r.Context(), `SELECT COALESCE((SELECT duration_ms FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1), 0)`, id).Scan(&durationMS); err != nil { h.fail(w, "load duration", err) return } posMS := ticksToMS(rep.PositionTicks) if posMS < 0 { posMS = 0 } p := progress.Progress{ ItemID: id, Kind: it.Kind, Position: posMS, Duration: durationMS, } if durationMS > 0 { p.Percent = float64(posMS) / float64(durationMS) if p.Percent > 1 { p.Percent = 1 } } if _, err := progress.Put(r.Context(), h.db, u.ID, p); err != nil { if errors.Is(err, progress.ErrNoFile) { // Nothing to attach progress to. Jellyfin clients expect 204, and // there is genuinely nothing to store, so do not fail playback. w.WriteHeader(http.StatusNoContent) return } h.fail(w, "save progress", err) return } w.WriteHeader(http.StatusNoContent) }