2026-07-27 08:16:20 +12:00
|
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
2026-07-27 21:06:51 +12:00
|
|
|
|
"strconv"
|
2026-07-27 08:16:20 +12:00
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const ticksPerMillisecond = 10_000
|
|
|
|
|
|
|
|
|
|
|
|
type playbackResponse struct {
|
2026-07-29 15:26:27 +12:00
|
|
|
|
ItemID string `json:"itemId"`
|
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
|
ResumePositionMs int64 `json:"resumePositionMs"`
|
|
|
|
|
|
Subtitles []playableSubtitle `json:"subtitles"`
|
|
|
|
|
|
MediaSourceID string `json:"mediaSourceId"`
|
|
|
|
|
|
PlaySessionID string `json:"playSessionId"`
|
|
|
|
|
|
PlayMethod string `json:"playMethod"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type playableSubtitle struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
|
MimeType string `json:"mimeType"`
|
|
|
|
|
|
Language string `json:"language,omitempty"`
|
|
|
|
|
|
Label string `json:"label,omitempty"`
|
|
|
|
|
|
IsDefault bool `json:"isDefault"`
|
|
|
|
|
|
IsForced bool `json:"isForced"`
|
|
|
|
|
|
IsHearingImpaired bool `json:"isHearingImpaired"`
|
|
|
|
|
|
DeliveryMethod string `json:"deliveryMethod"`
|
|
|
|
|
|
Codec string `json:"codec,omitempty"`
|
2026-07-27 08:16:20 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type playbackReport struct {
|
2026-07-29 15:26:27 +12:00
|
|
|
|
ItemID string `json:"itemId"`
|
|
|
|
|
|
PositionMs int64 `json:"positionMs"`
|
|
|
|
|
|
IsPaused bool `json:"isPaused"`
|
|
|
|
|
|
MediaSourceID string `json:"mediaSourceId"`
|
|
|
|
|
|
PlaySessionID string `json:"playSessionId"`
|
|
|
|
|
|
PlayMethod string `json:"playMethod"`
|
|
|
|
|
|
EventName string `json:"eventName,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// nextEpisodeResponse carries the episode that follows the one being watched. Item is
|
|
|
|
|
|
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so
|
|
|
|
|
|
// the client decodes it into the same BaseItem it uses everywhere else.
|
|
|
|
|
|
type nextEpisodeResponse struct {
|
|
|
|
|
|
Item json.RawMessage `json:"item"`
|
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
|
ResumePositionMs int64 `json:"resumePositionMs"`
|
|
|
|
|
|
Subtitles []playableSubtitle `json:"subtitles"`
|
|
|
|
|
|
MediaSourceID string `json:"mediaSourceId"`
|
|
|
|
|
|
PlaySessionID string `json:"playSessionId"`
|
|
|
|
|
|
PlayMethod string `json:"playMethod"`
|
2026-07-27 08:16:20 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
var subtitleIndex *int
|
|
|
|
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" {
|
|
|
|
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 {
|
|
|
|
|
|
subtitleIndex = &parsed
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
|
|
|
|
|
|
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
|
|
|
|
|
|
)
|
|
|
|
|
|
streamURL := s.emby.StreamURL(cred, target.ID)
|
|
|
|
|
|
if negotiatedURL != "" {
|
|
|
|
|
|
streamURL = negotiatedURL
|
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
|
writeJSON(w, http.StatusOK, playbackResponse{
|
|
|
|
|
|
ItemID: target.ID,
|
|
|
|
|
|
Title: title,
|
2026-07-29 15:26:27 +12:00
|
|
|
|
URL: streamURL,
|
2026-07-27 08:16:20 +12:00
|
|
|
|
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
2026-07-29 15:26:27 +12:00
|
|
|
|
Subtitles: subtitles,
|
|
|
|
|
|
MediaSourceID: mediaSourceID,
|
|
|
|
|
|
PlaySessionID: playSessionID,
|
|
|
|
|
|
PlayMethod: playMethod,
|
2026-07-27 08:16:20 +12:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
// handleNextEpisode resolves the episode that follows the one being watched, so the player
|
|
|
|
|
|
// can offer a "next up" countdown without the TV needing to know how Emby orders a series.
|
|
|
|
|
|
//
|
|
|
|
|
|
// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an
|
|
|
|
|
|
// unreadable series all come back as 404 and the client simply shows no banner.
|
|
|
|
|
|
func (s *Server) handleNextEpisode(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)
|
|
|
|
|
|
|
|
|
|
|
|
// The client already knows which series it launched, so accepting it as a hint keeps
|
|
|
|
|
|
// this off Emby for one round trip. Older clients omit it and we look it up.
|
|
|
|
|
|
seriesID := strings.TrimSpace(r.URL.Query().Get("seriesId"))
|
|
|
|
|
|
if seriesID == "" {
|
|
|
|
|
|
raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.writeUpstreamError(w, err, "could not load the item")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
var parsed struct {
|
|
|
|
|
|
SeriesID string `json:"SeriesId"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
|
|
|
|
writeError(w, http.StatusBadGateway, "unreadable item from emby")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
seriesID = parsed.SeriesID
|
|
|
|
|
|
}
|
|
|
|
|
|
if seriesID == "" {
|
|
|
|
|
|
writeError(w, http.StatusNotFound, "this item is not part of a series")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
|
|
|
|
|
|
"AdjacentTo": {itemID},
|
|
|
|
|
|
"Fields": {"RunTimeTicks,Overview,SeriesName"},
|
|
|
|
|
|
"EnableUserData": {"true"},
|
|
|
|
|
|
"EnableImageTypes": {"Primary,Thumb"},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.writeUpstreamError(w, err, "could not load the next episode")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
raw, next, ok := episodeAfter(episodes.Items, itemID)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
writeError(w, http.StatusNotFound, "no episode follows this one")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
title := next.Name
|
|
|
|
|
|
if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
|
|
|
|
|
|
title = series + " – " + title
|
|
|
|
|
|
}
|
|
|
|
|
|
subtitles, mediaSourceID, playSessionID, _, playMethod := s.playbackSubtitles(
|
|
|
|
|
|
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "",
|
|
|
|
|
|
)
|
|
|
|
|
|
writeJSON(w, http.StatusOK, nextEpisodeResponse{
|
|
|
|
|
|
Item: raw,
|
|
|
|
|
|
Title: title,
|
|
|
|
|
|
URL: s.emby.StreamURL(cred, next.ID),
|
|
|
|
|
|
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
|
|
|
|
|
Subtitles: subtitles,
|
|
|
|
|
|
MediaSourceID: mediaSourceID,
|
|
|
|
|
|
PlaySessionID: playSessionID,
|
|
|
|
|
|
PlayMethod: playMethod,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Server) playbackSubtitles(
|
|
|
|
|
|
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
|
|
|
|
|
|
subtitleIndex *int, currentPlaySessionID string,
|
|
|
|
|
|
) ([]playableSubtitle, string, string, string, string) {
|
|
|
|
|
|
info, err := s.emby.PlaybackInfo(
|
|
|
|
|
|
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
|
|
|
|
|
|
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(info.MediaSources) == 0 {
|
|
|
|
|
|
return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]playableSubtitle, 0)
|
|
|
|
|
|
seen := make(map[string]bool)
|
|
|
|
|
|
source := info.MediaSources[0]
|
|
|
|
|
|
for _, stream := range source.MediaStreams {
|
|
|
|
|
|
if !strings.EqualFold(stream.Type, "Subtitle") || stream.Index < 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
method := stream.DeliveryMethod
|
|
|
|
|
|
if method == "" {
|
|
|
|
|
|
if stream.IsTextSubtitleStream {
|
|
|
|
|
|
method = "External"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
method = "Encode"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
delivery := ""
|
|
|
|
|
|
mimeType := subtitleMIME(stream.Codec, stream.DeliveryURL)
|
|
|
|
|
|
if strings.EqualFold(method, "External") && mimeType != "" {
|
|
|
|
|
|
if stream.DeliveryURL != "" {
|
|
|
|
|
|
delivery = s.emby.DeliveryURL(cred, stream.DeliveryURL)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delivery = s.emby.SubtitleURL(cred, itemID, source.ID, stream.Index, subtitleExtension(stream.Codec))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
key := strconv.Itoa(stream.Index)
|
|
|
|
|
|
if seen[key] {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[key] = true
|
|
|
|
|
|
label := strings.TrimSpace(stream.DisplayTitle)
|
|
|
|
|
|
if label == "" {
|
|
|
|
|
|
label = strings.TrimSpace(stream.Title)
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, playableSubtitle{
|
|
|
|
|
|
ID: strconv.Itoa(stream.Index),
|
|
|
|
|
|
URL: delivery,
|
|
|
|
|
|
MimeType: mimeType,
|
|
|
|
|
|
Language: strings.TrimSpace(stream.Language),
|
|
|
|
|
|
Label: label,
|
|
|
|
|
|
IsDefault: stream.IsDefault,
|
|
|
|
|
|
IsForced: stream.IsForced,
|
|
|
|
|
|
IsHearingImpaired: stream.IsHearingImpaired ||
|
|
|
|
|
|
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "sdh") ||
|
|
|
|
|
|
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "hearing"),
|
|
|
|
|
|
DeliveryMethod: method,
|
|
|
|
|
|
Codec: stream.Codec,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
negotiatedURL := ""
|
|
|
|
|
|
playMethod := "DirectPlay"
|
|
|
|
|
|
if subtitleIndex != nil && source.TranscodingURL != "" {
|
|
|
|
|
|
negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL)
|
|
|
|
|
|
playMethod = "Transcode"
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func subtitleExtension(codec string) string {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(codec)) {
|
|
|
|
|
|
case "subrip":
|
|
|
|
|
|
return "srt"
|
|
|
|
|
|
case "webvtt":
|
|
|
|
|
|
return "vtt"
|
|
|
|
|
|
case "tx3g":
|
|
|
|
|
|
return "mov_text"
|
|
|
|
|
|
default:
|
|
|
|
|
|
if strings.TrimSpace(codec) == "" {
|
|
|
|
|
|
return "vtt"
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.ToLower(strings.TrimSpace(codec))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func subtitleMIME(codec, delivery string) string {
|
|
|
|
|
|
value := strings.ToLower(strings.TrimSpace(codec))
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
path := delivery
|
|
|
|
|
|
if parsed, err := url.Parse(delivery); err == nil {
|
|
|
|
|
|
path = parsed.Path
|
|
|
|
|
|
}
|
|
|
|
|
|
if dot := strings.LastIndex(path, "."); dot >= 0 {
|
|
|
|
|
|
value = strings.ToLower(path[dot+1:])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
switch value {
|
|
|
|
|
|
case "srt", "subrip":
|
|
|
|
|
|
return "application/x-subrip"
|
|
|
|
|
|
case "vtt", "webvtt":
|
|
|
|
|
|
return "text/vtt"
|
|
|
|
|
|
case "ass", "ssa":
|
|
|
|
|
|
return "text/x-ssa"
|
|
|
|
|
|
case "ttml", "dfxp":
|
|
|
|
|
|
return "application/ttml+xml"
|
|
|
|
|
|
case "tx3g", "mov_text":
|
|
|
|
|
|
return "application/x-quicktime-tx3g"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// episodeAfter picks the episode following currentID out of an AdjacentTo result, which
|
|
|
|
|
|
// Emby returns in running order as [previous, current, next] minus whichever ends do not
|
|
|
|
|
|
// exist — so the position of the current episode is what identifies the next one, not the
|
|
|
|
|
|
// length of the list.
|
|
|
|
|
|
func episodeAfter(items []json.RawMessage, currentID string) (json.RawMessage, emby.Summary, bool) {
|
|
|
|
|
|
for i, raw := range items {
|
|
|
|
|
|
summary, err := emby.Summarise(raw)
|
|
|
|
|
|
if err != nil || summary.ID != currentID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if i+1 >= len(items) {
|
|
|
|
|
|
return nil, emby.Summary{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
next, err := emby.Summarise(items[i+1])
|
|
|
|
|
|
if err != nil || next.ID == "" {
|
|
|
|
|
|
return nil, emby.Summary{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
return items[i+1], next, true
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, emby.Summary{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func seriesNameOf(raw json.RawMessage) string {
|
|
|
|
|
|
var parsed struct {
|
|
|
|
|
|
SeriesName string `json:"SeriesName"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return parsed.SeriesName
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
err := s.emby.ReportPlayback(
|
|
|
|
|
|
r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID,
|
|
|
|
|
|
report.PlaySessionID, report.PlayMethod, report.EventName,
|
|
|
|
|
|
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
|
|
|
|
|
|
)
|
2026-07-27 08:16:20 +12:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
|
if s.forYou != nil {
|
|
|
|
|
|
s.forYou.MarkDirty(r.Context(), sess)
|
|
|
|
|
|
s.forYou.RefreshAsync(sess, true)
|
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
|
}
|
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func max64(v, floor int64) int64 {
|
|
|
|
|
|
if v < floor {
|
|
|
|
|
|
return floor
|
|
|
|
|
|
}
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|