160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
package api
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"net/http"
|
|||
|
|
"net/url"
|
|||
|
|
"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)
|
|||
|
|
|
|||
|
|
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),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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
|
|||
|
|
}
|