App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
+126
-46
@@ -27,9 +27,18 @@ type playbackResponse struct {
|
||||
URL string `json:"url"`
|
||||
ResumePositionMs int64 `json:"resumePositionMs"`
|
||||
Subtitles []playableSubtitle `json:"subtitles"`
|
||||
MediaSourceID string `json:"mediaSourceId"`
|
||||
PlaySessionID string `json:"playSessionId"`
|
||||
PlayMethod string `json:"playMethod"`
|
||||
// Which of those tracks to turn on, decided from the viewer's synced settings rather
|
||||
// than by the television. Empty with SubtitlesEnabled true means "nothing suitable".
|
||||
SubtitlesEnabled bool `json:"subtitlesEnabled"`
|
||||
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
|
||||
MediaSourceID string `json:"mediaSourceId"`
|
||||
PlaySessionID string `json:"playSessionId"`
|
||||
PlayMethod string `json:"playMethod"`
|
||||
// Whether this gateway can fetch a subtitle the title does not have. It rides here
|
||||
// rather than on /v1/status because the drop-up is the only thing that asks, it
|
||||
// already holds this response, and one boolean on a request that is made once per
|
||||
// playback is cheaper than a field on the poll every open TV makes every ten seconds.
|
||||
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
|
||||
}
|
||||
|
||||
type playableSubtitle struct {
|
||||
@@ -64,14 +73,16 @@ type playbackReportResponse struct {
|
||||
// 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"`
|
||||
Item json.RawMessage `json:"item"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ResumePositionMs int64 `json:"resumePositionMs"`
|
||||
Subtitles []playableSubtitle `json:"subtitles"`
|
||||
SubtitlesEnabled bool `json:"subtitlesEnabled"`
|
||||
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
|
||||
MediaSourceID string `json:"mediaSourceId"`
|
||||
PlaySessionID string `json:"playSessionId"`
|
||||
PlayMethod string `json:"playMethod"`
|
||||
}
|
||||
|
||||
// handlePlayback resolves what to actually play.
|
||||
@@ -92,7 +103,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
if !hinted {
|
||||
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not load the item")
|
||||
s.writeUpstreamError(ctx, w, err, "could not load the item")
|
||||
return
|
||||
}
|
||||
item, err = emby.Summarise(raw)
|
||||
@@ -108,7 +119,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
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")
|
||||
s.writeUpstreamError(ctx, w, err, "could not find an episode to play")
|
||||
return
|
||||
}
|
||||
if episode == nil {
|
||||
@@ -135,29 +146,55 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
if negotiatedURL != "" {
|
||||
streamURL = negotiatedURL
|
||||
}
|
||||
subtitlesEnabled, subtitleLanguage := s.subtitlePreferenceFor(ctx, sess)
|
||||
selectedSubtitleID := selectSubtitle(subtitles, subtitlesEnabled, subtitleLanguage)
|
||||
// An explicit index is the viewer choosing a burned-in track in the player. It is a
|
||||
// decision already made, so it outranks the stored preference for this stream.
|
||||
if subtitleIndex != nil {
|
||||
subtitlesEnabled, selectedSubtitleID = true, strconv.Itoa(*subtitleIndex)
|
||||
}
|
||||
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)
|
||||
s.loggerFor(ctx).Warn("playback policy unavailable", "error", policyErr)
|
||||
}
|
||||
}
|
||||
// The one event that says what somebody actually tried to watch. It is logged even
|
||||
// though the request line already records the route, because the route carries an
|
||||
// item id and nobody can read an item id.
|
||||
s.playbackTitles.remember(target.ID, title)
|
||||
s.loggerFor(ctx).Info("playback requested",
|
||||
"title", title,
|
||||
"item", target.ID,
|
||||
"type", target.Type,
|
||||
"play_method", clientLogValue(playMethod),
|
||||
"resume", millisecondDuration(target.UserData.PlaybackPositionTicks/ticksPerMillisecond),
|
||||
"runtime", millisecondDuration(target.RunTimeTicks/ticksPerMillisecond),
|
||||
"subtitles", len(subtitles),
|
||||
"subtitle_track", clientLogValue(selectedSubtitleID),
|
||||
"subtitle_language", clientLogValue(subtitleLanguage),
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, playbackResponse{
|
||||
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,
|
||||
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,
|
||||
SubtitlesEnabled: subtitlesEnabled,
|
||||
SelectedSubtitleID: selectedSubtitleID,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -245,7 +282,7 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
if seriesID == "" {
|
||||
raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId")
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not load the item")
|
||||
s.writeUpstreamError(ctx, w, err, "could not load the item")
|
||||
return
|
||||
}
|
||||
var parsed struct {
|
||||
@@ -269,7 +306,7 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
"EnableImageTypes": {"Primary,Thumb"},
|
||||
})
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not load the next episode")
|
||||
s.writeUpstreamError(ctx, w, err, "could not load the next episode")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,15 +328,31 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
if negotiatedURL != "" {
|
||||
streamURL = negotiatedURL
|
||||
}
|
||||
// The same choice as the episode the viewer is already watching, made the same way:
|
||||
// auto-advance must not quietly drop the subtitles they had on a minute ago.
|
||||
subtitlesEnabled, subtitleLanguage := s.subtitlePreferenceFor(ctx, sess)
|
||||
selectedSubtitleID := selectSubtitle(subtitles, subtitlesEnabled, subtitleLanguage)
|
||||
// The player asks for this ~30s before an episode ends, so the line is also the
|
||||
// record of an auto-advance about to happen.
|
||||
s.playbackTitles.remember(next.ID, title)
|
||||
s.loggerFor(ctx).Info("next episode resolved",
|
||||
"title", title,
|
||||
"item", next.ID,
|
||||
"after_item", itemID,
|
||||
"play_method", clientLogValue(playMethod),
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, nextEpisodeResponse{
|
||||
Item: raw,
|
||||
Title: title,
|
||||
URL: streamURL,
|
||||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||||
Subtitles: subtitles,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
Item: raw,
|
||||
Title: title,
|
||||
URL: streamURL,
|
||||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||||
Subtitles: subtitles,
|
||||
SubtitlesEnabled: subtitlesEnabled,
|
||||
SelectedSubtitleID: selectedSubtitleID,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -313,7 +366,7 @@ func (s *Server) playbackSubtitles(
|
||||
capabilities,
|
||||
)
|
||||
if err != nil {
|
||||
s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
|
||||
s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
|
||||
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
|
||||
}
|
||||
if len(info.MediaSources) == 0 {
|
||||
@@ -587,6 +640,11 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
return
|
||||
}
|
||||
|
||||
log := s.loggerFor(r.Context()).With(
|
||||
"title", s.playbackTitles.name(report.ItemID),
|
||||
"item", report.ItemID,
|
||||
)
|
||||
|
||||
err := s.emby.ReportPlayback(
|
||||
r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID,
|
||||
report.PlaySessionID, report.PlayMethod, report.EventName,
|
||||
@@ -594,12 +652,34 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
)
|
||||
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)
|
||||
log.Warn("playback report failed", "phase", phase, "error", err)
|
||||
}
|
||||
|
||||
// Start and stop are the shape of an evening's viewing and belong in the normal log.
|
||||
// Progress arrives every ten seconds for the length of a film, so it is DEBUG: useful
|
||||
// when investigating one stall, ruinous as a default.
|
||||
switch phase {
|
||||
case "started":
|
||||
log.Info("playback started",
|
||||
"position", millisecondDuration(report.PositionMs),
|
||||
"play_method", clientLogValue(report.PlayMethod),
|
||||
)
|
||||
case "stopped":
|
||||
log.Info("playback stopped",
|
||||
"position", millisecondDuration(report.PositionMs),
|
||||
"runtime", millisecondDuration(report.DurationMs),
|
||||
"watched", watchedPercent(report.PositionMs, report.DurationMs),
|
||||
)
|
||||
default:
|
||||
log.Debug("playback progress",
|
||||
"position", millisecondDuration(report.PositionMs),
|
||||
"paused", report.IsPaused,
|
||||
)
|
||||
}
|
||||
|
||||
if phase == "stopped" {
|
||||
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
|
||||
s.log.Warn("cache invalidation failed", "error", err)
|
||||
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
|
||||
}
|
||||
// Recommendation taste changes slowly. Tracearr marks this user's prepared
|
||||
// profile dirty only when the session first becomes terminal; the daily builder
|
||||
@@ -627,7 +707,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
}
|
||||
rawEpisode, err := s.emby.Item(ctx, credentials(sess), episodeID, "SeriesId")
|
||||
if err != nil {
|
||||
s.log.Warn("auto-follow episode lookup failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("auto-follow episode lookup failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
var episode struct {
|
||||
@@ -639,7 +719,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
}
|
||||
rawSeries, err := s.emby.Item(ctx, credentials(sess), episode.SeriesID, "ProductionYear")
|
||||
if err != nil {
|
||||
s.log.Warn("auto-follow series lookup failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("auto-follow series lookup failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
var seriesItem struct {
|
||||
@@ -652,7 +732,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
}
|
||||
sonarrSeries, err := s.sonarr.Series(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("auto-follow Sonarr lookup failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
matched := matchSonarrSeries(store.UserShow{Title: seriesItem.Name, Year: seriesItem.ProductionYear}, sonarrSeries)
|
||||
@@ -665,7 +745,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
}
|
||||
inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show)
|
||||
if err != nil {
|
||||
s.log.Warn("auto-follow save failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
if !inserted {
|
||||
@@ -673,7 +753,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
}
|
||||
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
|
||||
if err != nil {
|
||||
s.log.Warn("auto-follow notification preferences unavailable", "error", err)
|
||||
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
|
||||
return ""
|
||||
}
|
||||
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
|
||||
|
||||
Reference in New Issue
Block a user