Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+288 -10
View File
@@ -15,16 +15,51 @@ import (
const ticksPerMillisecond = 10_000
type playbackResponse struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
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"`
}
type playbackReport struct {
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
IsPaused bool `json:"isPaused"`
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"`
}
// handlePlayback resolves what to actually play.
@@ -74,11 +109,28 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
}
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
}
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
URL: s.emby.StreamURL(cred, target.ID),
URL: streamURL,
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
@@ -139,6 +191,225 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
return &summary, nil
}
// 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
}
// 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) {
@@ -160,8 +431,11 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
return
}
err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused)
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,
)
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)
@@ -176,6 +450,10 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("recommendation invalidation failed", "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
}
w.WriteHeader(http.StatusNoContent)
}