package api import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/store" ) const ticksPerMillisecond = 10_000 type playbackResponse struct { ItemID string `json:"itemId"` Title string `json:"title"` URL string `json:"url"` ResumePositionMs int64 `json:"resumePositionMs"` } type playbackReport struct { ItemID string `json:"itemId"` PositionMs int64 `json:"positionMs"` IsPaused bool `json:"isPaused"` } // handlePlayback resolves what to actually play. // // This is logic the TV used to carry: a series resolves to its next-up episode (falling // back to the first), and the returned URL points straight at Emby so the video stream // never traverses the gateway. func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() itemID := r.PathValue("id") if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } cred := credentials(sess) item, hinted := playbackHint(r, itemID) if !hinted { raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName") if err != nil { s.writeUpstreamError(w, err, "could not load the item") return } item, err = emby.Summarise(raw) if err != nil { writeError(w, http.StatusBadGateway, "unreadable item from emby") return } } target := item title := item.Name if strings.EqualFold(item.Type, "Series") { episode, err := s.firstPlayableEpisode(ctx, cred, item.ID) if err != nil { s.writeUpstreamError(w, err, "could not find an episode to play") return } if episode == nil { writeError(w, http.StatusNotFound, "no episodes found for this series") return } target = *episode if episode.Name != "" { title = item.Name + " – " + episode.Name } } writeJSON(w, http.StatusOK, playbackResponse{ ItemID: target.ID, Title: title, URL: s.emby.StreamURL(cred, target.ID), ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), }) } // Current clients already know the selected item's type, title and cached resume point. // Accepting those as hints removes one serial Emby request from every launch. Older // clients omit them and retain the authoritative lookup above. func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) { itemType := strings.TrimSpace(r.URL.Query().Get("type")) switch { case strings.EqualFold(itemType, "Movie"): itemType = "Movie" case strings.EqualFold(itemType, "Episode"): itemType = "Episode" case strings.EqualFold(itemType, "Series"): itemType = "Series" default: return emby.Summary{}, false } resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64) item := emby.Summary{ ID: itemID, Name: strings.TrimSpace(r.URL.Query().Get("title")), Type: itemType, } item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond return item, true } // firstPlayableEpisode prefers the server's next-up choice and falls back to episode one. func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) { nextUp, err := s.emby.NextUp(ctx, cred, url.Values{ "SeriesId": {seriesID}, "Limit": {"1"}, "Fields": {"RunTimeTicks"}, "EnableUserData": {"true"}, }) if err == nil && len(nextUp.Items) > 0 { if summary, err := emby.Summarise(nextUp.Items[0]); err == nil { return &summary, nil } } episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{ "Limit": {"1"}, "Fields": {"RunTimeTicks"}, "EnableUserData": {"true"}, }) if err != nil { return nil, err } if len(episodes.Items) == 0 { return nil, nil } summary, err := emby.Summarise(episodes.Items[0]) if err != nil { return nil, err } return &summary, nil } // handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache // so Continue Watching reflects the new position on the next home load. func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) { phase := r.PathValue("phase") switch phase { case "started", "progress", "stopped": default: writeError(w, http.StatusNotFound, "unknown playback phase") return } var report playbackReport if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&report); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if report.ItemID == "" { writeError(w, http.StatusBadRequest, "itemId is required") return } err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID, max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused) if err != nil { // A dropped progress report is not worth failing playback over; log and accept. s.log.Warn("playback report failed", "phase", phase, "error", err) } if phase == "stopped" { if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("cache invalidation failed", "error", err) } // Finishing something is the one event that genuinely changes viewing history, // so it is also the only thing that retires the recommendation rows. if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("recommendation invalidation failed", "error", err) } } w.WriteHeader(http.StatusNoContent) } func max64(v, floor int64) int64 { if v < floor { return floor } return v }