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:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+126 -46
View File
@@ -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) {