Publish current app and server
This commit is contained in:
+268
-39
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user