Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+268 -39
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
@@ -15,14 +16,20 @@ import (
const ticksPerMillisecond = 10_000
type playbackResponse struct {
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"`
ItemID string `json:"itemId"`
Title string `json:"title"`
Overview string `json:"overview,omitempty"`
SeriesName string `json:"seriesName,omitempty"`
EpisodeCode string `json:"episodeCode,omitempty"`
RuntimeMs int64 `json:"runtimeMs,omitempty"`
PrerollEnabled bool `json:"prerollEnabled"`
PrerollDurationMs int64 `json:"prerollDurationMs"`
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 {
@@ -41,6 +48,7 @@ type playableSubtitle struct {
type playbackReport struct {
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
DurationMs int64 `json:"durationMs,omitempty"`
IsPaused bool `json:"isPaused"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
@@ -48,6 +56,10 @@ type playbackReport struct {
EventName string `json:"eventName,omitempty"`
}
type playbackReportResponse struct {
AutoFollowedShowTitle string `json:"autoFollowedShowTitle,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.
@@ -117,20 +129,35 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess),
)
streamURL := s.emby.StreamURL(cred, target.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
playbackPolicy := store.DefaultPlaybackPolicy()
if s.store != nil {
if policy, policyErr := s.store.PlaybackPolicy(ctx); policyErr == nil {
playbackPolicy = policy
} else {
s.log.Warn("playback policy unavailable", "error", policyErr)
}
}
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
URL: streamURL,
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
ItemID: target.ID,
Title: title,
Overview: target.Overview,
SeriesName: target.SeriesName,
EpisodeCode: episodeCode(target),
RuntimeMs: max64(target.RunTimeTicks/ticksPerMillisecond, 0),
PrerollEnabled: playbackPolicy.PrerollEnabled && s.featureEnabled(ctx, featureSonarrPreroll),
PrerollDurationMs: playbackPolicy.PrerollDurationMs,
URL: streamURL,
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
@@ -164,7 +191,7 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
"SeriesId": {seriesID},
"Limit": {"1"},
"Fields": {"RunTimeTicks"},
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
"EnableUserData": {"true"},
})
if err == nil && len(nextUp.Items) > 0 {
@@ -175,7 +202,7 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
"Limit": {"1"},
"Fields": {"RunTimeTicks"},
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
"EnableUserData": {"true"},
})
if err != nil {
@@ -191,6 +218,13 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
return &summary, nil
}
func episodeCode(item emby.Summary) string {
if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 {
return ""
}
return fmt.Sprintf("S%02dE%02d", item.ParentIndexNumber, item.IndexNumber)
}
// 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.
//
@@ -249,13 +283,18 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
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, "",
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false,
s.effectivePlaybackCapabilities(ctx, sess),
)
streamURL := s.emby.StreamURL(cred, next.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
URL: s.emby.StreamURL(cred, next.ID),
URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
@@ -266,10 +305,12 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
func (s *Server) playbackSubtitles(
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
subtitleIndex *int, currentPlaySessionID string,
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
capabilities emby.PlaybackCapabilities,
) ([]playableSubtitle, string, string, string, string) {
info, err := s.emby.PlaybackInfo(
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID,
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
capabilities,
)
if err != nil {
s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
@@ -326,13 +367,128 @@ func (s *Server) playbackSubtitles(
Codec: stream.Codec,
})
}
negotiatedURL := ""
playMethod := "DirectPlay"
if subtitleIndex != nil && source.TranscodingURL != "" {
negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL)
playMethod = "Transcode"
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
if delivery != "" {
delivery = s.emby.DeliveryURL(cred, delivery)
}
return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod
return out, source.ID, info.PlaySessionID, delivery, playMethod
}
func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
capabilities := emby.PlaybackCapabilities{}
for _, value := range sess.ClientCapabilities {
switch value {
case "video_h264_profile_baseline":
capabilities.H264Profiles = append(capabilities.H264Profiles, "baseline")
case "video_h264_profile_constrained_baseline":
capabilities.H264Profiles = append(capabilities.H264Profiles, "constrained baseline")
case "video_h264_profile_main":
capabilities.H264Profiles = append(capabilities.H264Profiles, "main")
case "video_h264_profile_high":
capabilities.H264Profiles = append(capabilities.H264Profiles, "high")
case "video_h264_profile_high10":
capabilities.H264Profiles = append(capabilities.H264Profiles, "high 10")
case "video_hevc_decode":
capabilities.HEVC = true
case "video_hevc_profile_main":
capabilities.HEVCMain = true
case "video_hevc_profile_main10":
capabilities.HEVCMain10 = true
case "video_hevc_hdr10":
capabilities.HEVCHDR10 = true
case "video_hevc_hdr10plus":
capabilities.HEVCHDR10Plus = true
case "video_hevc_dolby_vision":
capabilities.HEVCDolbyVision = true
default:
parsePlaybackCapabilityValue(value, &capabilities)
}
}
return capabilities
}
func (s *Server) effectivePlaybackCapabilities(
ctx context.Context, sess store.Session,
) emby.PlaybackCapabilities {
capabilities := sessionPlaybackCapabilities(sess)
if !s.featureEnabled(ctx, featureHEVCDirectPlay) {
capabilities.HEVC = false
capabilities.HEVCMain = false
capabilities.HEVCMain10 = false
capabilities.HEVCMainLevel = 0
capabilities.HEVCMain10Level = 0
capabilities.HEVCMaxWidth = 0
capabilities.HEVCMaxHeight = 0
capabilities.HEVCHDR10 = false
capabilities.HEVCHDR10Plus = false
capabilities.HEVCDolbyVision = false
}
return capabilities
}
func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) {
parseIntCapability(value, "video_h264_level_", &capabilities.H264Level)
parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level)
parseIntCapability(value, "video_hevc_main_level_", &capabilities.HEVCMainLevel)
parseIntCapability(value, "video_hevc_main10_level_", &capabilities.HEVCMain10Level)
parseResolutionCapability(
value, "video_h264_max_", &capabilities.H264MaxWidth, &capabilities.H264MaxHeight,
)
parseResolutionCapability(
value, "video_hevc_max_", &capabilities.HEVCMaxWidth, &capabilities.HEVCMaxHeight,
)
}
func parseIntCapability(value, prefix string, destination *int) {
if !strings.HasPrefix(value, prefix) {
return
}
parsed, err := strconv.Atoi(strings.TrimPrefix(value, prefix))
if err == nil && parsed > 0 {
*destination = parsed
}
}
func parseResolutionCapability(value, prefix string, width, height *int) {
if !strings.HasPrefix(value, prefix) {
return
}
parts := strings.Split(strings.TrimPrefix(value, prefix), "x")
if len(parts) != 2 {
return
}
parsedWidth, widthErr := strconv.Atoi(parts[0])
parsedHeight, heightErr := strconv.Atoi(parts[1])
if widthErr == nil && heightErr == nil && parsedWidth > 0 && parsedHeight > 0 {
*width, *height = parsedWidth, parsedHeight
}
}
func selectPlaybackDelivery(source emby.MediaSourceInfo, forceTranscode bool) (string, string) {
if forceTranscode && source.TranscodingURL != "" {
return source.TranscodingURL, "Transcode"
}
if source.SupportsDirectPlay {
return "", "DirectPlay"
}
if source.SupportsDirectStream && source.DirectStreamURL != "" {
return source.DirectStreamURL, "DirectStream"
}
if source.SupportsTranscoding && source.TranscodingURL != "" {
return source.TranscodingURL, "Transcode"
}
if source.DirectStreamURL != "" {
return source.DirectStreamURL, "DirectStream"
}
if source.TranscodingURL != "" {
return source.TranscodingURL, "Transcode"
}
return "", "DirectPlay"
}
func queryBool(r *http.Request, name string) bool {
value, err := strconv.ParseBool(strings.TrimSpace(r.URL.Query().Get(name)))
return err == nil && value
}
func subtitleExtension(codec string) string {
@@ -445,17 +601,90 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
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)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
// Recommendation taste changes slowly. Tracearr marks this user's prepared
// profile dirty only when the session first becomes terminal; the daily builder
// then refreshes it without turning every player exit into catalogue-wide work.
}
w.WriteHeader(http.StatusNoContent)
response := playbackReportResponse{}
if shouldAutoFollowShow(phase, report.PositionMs, report.DurationMs) &&
s.featureEnabled(r.Context(), featureAutomaticMyShows) {
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, report.ItemID)
}
writeJSON(w, http.StatusOK, response)
}
// Half an episode is a meaningful intent signal without making somebody finish an
// episode they dislike. The insert below is the durable deduplication boundary, so
// later ten-second progress reports are harmless.
func shouldAutoFollowShow(phase string, positionMs, durationMs int64) bool {
return phase != "started" && durationMs > 0 && positionMs >= (durationMs+1)/2
}
func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Session, episodeID string) string {
if s.sonarr == nil || s.store == nil {
return ""
}
rawEpisode, err := s.emby.Item(ctx, credentials(sess), episodeID, "SeriesId")
if err != nil {
s.log.Warn("auto-follow episode lookup failed", "error", err)
return ""
}
var episode struct {
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
}
if json.Unmarshal(rawEpisode, &episode) != nil || !strings.EqualFold(episode.Type, "Episode") || episode.SeriesID == "" {
return ""
}
rawSeries, err := s.emby.Item(ctx, credentials(sess), episode.SeriesID, "ProductionYear")
if err != nil {
s.log.Warn("auto-follow series lookup failed", "error", err)
return ""
}
var seriesItem struct {
Name string `json:"Name"`
ProductionYear *int `json:"ProductionYear"`
ImageTags map[string]string `json:"ImageTags"`
}
if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" {
return ""
}
sonarrSeries, err := s.sonarr.Series(ctx)
if err != nil {
s.log.Warn("auto-follow Sonarr lookup failed", "error", err)
return ""
}
matched := matchSonarrSeries(store.UserShow{Title: seriesItem.Name, Year: seriesItem.ProductionYear}, sonarrSeries)
if matched == nil || !isContinuingSonarrStatus(matched.Status) {
return ""
}
show := store.UserShow{
ItemID: episode.SeriesID, Title: seriesItem.Name, Year: seriesItem.ProductionYear,
ImageTag: seriesItem.ImageTags["Primary"],
}
inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show)
if err != nil {
s.log.Warn("auto-follow save failed", "error", err)
return ""
}
if !inserted {
return ""
}
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
if err != nil {
s.log.Warn("auto-follow notification preferences unavailable", "error", err)
return ""
}
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
_ = s.store.UpsertNotification(
ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow",
episode.SeriesID, "Added to My Shows",
seriesItem.Name+" was added because you started watching it and it is still continuing.", nil,
)
return seriesItem.Name
}
return ""
}
func max64(v, floor int64) int64 {