Files
memby/server/internal/api/playback.go
T
2026-07-27 21:06:51 +12:00

189 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}