Improve playback, preroll and TV experience

This commit is contained in:
ponzischeme89
2026-08-10 07:11:14 +12:00
parent 598a4f5c75
commit d2f2eb62be
30 changed files with 1380 additions and 142 deletions
+2
View File
@@ -168,6 +168,8 @@ func (s *Server) Routes() http.Handler {
v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/people/{id}", s.authed(s.handlePerson))
v1.Handle("GET /v1/people/{id}/filmography", s.authed(s.handlePersonFilmography))
v1.Handle("GET /v1/items/{id}/ratings", s.authed(s.handleMovieRatings))
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"encoding/json"
"net/http"
"net/url"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
type personFilmographyResponse struct {
Items []json.RawMessage `json:"items"`
}
// handlePerson returns Emby's person item. PremiereDate and EndDate are the person's
// birth and death dates; Overview is their biography.
func (s *Server) handlePerson(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
personID := r.PathValue("id")
if personID == "" {
writeError(w, http.StatusBadRequest, "person id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "person:v1:"+personID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
person, err := s.emby.Item(
ctx,
credentials(sess),
personID,
"Overview,Genres,PrimaryImageAspectRatio",
)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the person")
return
}
if err := s.cache.Set(ctx, key, person, s.cfg.ItemTTL); err != nil {
s.loggerFor(ctx).Warn("person cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, person)
}
// Filmography is kept separate from the biography so life dates can decorate the cast
// row without also loading every cast member's credits.
func (s *Server) handlePersonFilmography(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
personID := r.PathValue("id")
if personID == "" {
writeError(w, http.StatusBadRequest, "person id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "person-filmography:v1:"+personID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
result, err := s.emby.Items(ctx, credentials(sess), url.Values{
"PersonIds": {personID},
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"SortBy": {"ProductionYear,SortName"},
"SortOrder": {"Descending"},
"Limit": {"60"},
"Fields": {"Overview,ProductionYear,PrimaryImageAspectRatio"},
"EnableImages": {"true"},
"EnableImageTypes": {"Primary"},
"ImageTypeLimit": {"1"},
"EnableUserData": {"true"},
})
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the person's filmography")
return
}
items := result.Items
if items == nil {
items = []json.RawMessage{}
}
body, err := json.Marshal(personFilmographyResponse{Items: items})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode the filmography")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.loggerFor(ctx).Warn("person filmography cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
+6 -6
View File
@@ -297,12 +297,16 @@ func (c *Client) PlaybackInfo(
"UserId": {cred.UserID},
"IsPlayback": {"true"},
}
profile := androidTVDeviceProfile(capabilities)
if forceTranscode {
forceH264TranscodeProfile(profile)
}
body, err := json.Marshal(map[string]any{
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
"AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true,
"DeviceProfile": androidTVDeviceProfile(capabilities),
"AllowVideoStreamCopy": !forceTranscode, "AllowAudioStreamCopy": true,
"DeviceProfile": profile,
})
var requestBody map[string]any
if err == nil {
@@ -314,10 +318,6 @@ func (c *Client) PlaybackInfo(
if currentPlaySessionID != "" {
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
}
if forceTranscode {
profile := requestBody["DeviceProfile"].(map[string]any)
profile["DirectPlayProfiles"] = []map[string]string{}
}
if err == nil {
body, err = json.Marshal(requestBody)
}
@@ -32,3 +32,23 @@ func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
t.Fatalf("codec profiles = %#v", codecProfiles)
}
}
func TestForcedTranscodeProfileCannotStreamCopyHEVC(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{
HEVC: true, HEVCMain: true, HEVCMain10: true,
})
forceH264TranscodeProfile(profile)
if direct := profile["DirectPlayProfiles"].([]map[string]string); len(direct) != 0 {
t.Fatalf("direct profiles = %#v", direct)
}
transcode := profile["TranscodingProfiles"].([]map[string]string)
if len(transcode) != 1 || transcode[0]["VideoCodec"] != "h264" {
t.Fatalf("transcoding profiles = %#v", transcode)
}
for _, codecProfile := range profile["CodecProfiles"].([]map[string]any) {
if codecProfile["Codec"] == "hevc" {
t.Fatalf("HEVC constraint survived fallback: %#v", codecProfile)
}
}
}
+21
View File
@@ -53,6 +53,27 @@ func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
}
}
// forceH264TranscodeProfile turns decoder recovery into an actual codec change. Merely
// removing DirectPlayProfiles is insufficient: Emby may otherwise stream-copy HEVC into
// the ordinary HLS transcoding profile and hand the failing decoder the same video again.
func forceH264TranscodeProfile(profile map[string]any) {
profile["DirectPlayProfiles"] = []map[string]string{}
profile["TranscodingProfiles"] = []map[string]string{
{
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
}
profiles, _ := profile["CodecProfiles"].([]map[string]any)
h264Profiles := make([]map[string]any, 0, len(profiles))
for _, codecProfile := range profiles {
if codec, _ := codecProfile["Codec"].(string); codec == "h264" {
h264Profiles = append(h264Profiles, codecProfile)
}
}
profile["CodecProfiles"] = h264Profiles
}
func androidTVSubtitleProfiles() []map[string]string {
return []map[string]string{
{"Format": "srt", "Method": "External"},