This commit is contained in:
ponzischeme89
2026-08-20 15:06:00 +12:00
parent 549f9c5eed
commit f1164db2c5
52 changed files with 5441 additions and 158 deletions
+122 -16
View File
@@ -129,6 +129,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
return
}
cred := credentials(sess)
viewer := viewerOf(ctx, sess)
item, hinted := playbackHint(r, itemID)
if !hinted {
@@ -148,7 +149,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
title := item.Name
if strings.EqualFold(item.Type, "Series") {
episode, err := s.firstPlayableEpisode(ctx, cred, item.ID)
episode, err := s.firstPlayableEpisode(ctx, cred, viewer, item.ID)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not find an episode to play")
return
@@ -163,6 +164,26 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
}
// Where a shadow viewer resumes from is Memby's answer, and it is taken here rather
// than trusted from the card.
//
// The hint the television sends is read off a card this gateway already decorated with
// this viewer's own state, so the two normally agree — but only normally. The store has
// heard about the episode they were part-way through on the other television, and a
// card is only as fresh as the last home refresh. This is also the value handed to
// PlaybackInfo below, so taking it here fixes the negotiated stream as well as the
// number sent back.
if !viewer.IsMain() && s.store != nil {
if state, err := s.store.ViewerStateFor(ctx, viewer.ID, target.ID); err == nil {
target.UserData.PlaybackPositionTicks = state.PositionTicks
} else {
// Starting from the beginning is a recoverable disappointment; starting from
// where somebody else got to is not.
s.loggerFor(ctx).Warn("viewer resume position unavailable", "error", err)
target.UserData.PlaybackPositionTicks = 0
}
}
var subtitleIndex *int
if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 {
@@ -265,7 +286,26 @@ func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
}
// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
// The viewer is threaded in because "where does this series start" is a question about a
// person, and Emby's NextUp answers it for the account. A shadow viewer's answer is their
// own: the first episode they have not finished.
func (s *Server) firstPlayableEpisode(
ctx context.Context, cred emby.Credentials, viewer store.Viewer, seriesID string,
) (*emby.Summary, error) {
if !viewer.IsMain() && s.store != nil {
episode, err := s.firstUnwatchedEpisodeFor(ctx, cred, viewer, seriesID)
if err != nil {
// Falling through to Emby's answer is wrong for this viewer, so it is not
// done: starting somebody at the account's next episode is the leak this
// feature exists to prevent.
return nil, err
}
if episode != nil {
return episode, nil
}
// Nothing recorded for this series yet: fall through and let Emby name its first
// episode, which is the right answer for somebody who has never watched any of it.
}
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
"SeriesId": {seriesID},
"Limit": {"1"},
@@ -376,6 +416,20 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
title = series + " " + title
}
// Which episode follows is a property of the season and is the same for everybody;
// how far into it *this* viewer already is, is not. The item payload is rewritten as
// well as the summary, because the television draws the next-up banner from it.
viewer := viewerOf(ctx, sess)
if !viewer.IsMain() && s.store != nil {
state, stateErr := s.store.ViewerStateFor(ctx, viewer.ID, next.ID)
if stateErr != nil {
s.loggerFor(ctx).Warn("viewer next-episode position unavailable", "error", stateErr)
state = store.ViewerState{}
}
next.UserData.PlaybackPositionTicks = state.PositionTicks
raw = injectItemUserData(raw, viewerUserData(state))
}
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation,
// and a client that said it does not want one yet must not be given one anyway.
if !nextEpisodeWantsStream(r) {
@@ -798,6 +852,41 @@ func seriesNameOf(raw json.RawMessage) string {
return parsed.SeriesName
}
// recordShadowPlayback is the other side of the playback report: the same three phases,
// written to Memby instead of to Emby.
//
// Where a title is *finished* is decided here rather than by the television, for the
// reason the gateway decides which subtitle comes on: Emby applies its own completion
// threshold on the main viewer's behalf, and a shadow viewer must be judged by the same
// rule or one household would disagree with itself about whether an episode is watched
// depending on who watched it.
//
// A paused progress report still records the position. Pausing is where somebody leaves a
// film, and the ten seconds between reports is exactly the window a set switched off at
// the wall would otherwise lose.
func (s *Server) recordShadowPlayback(
ctx context.Context, viewer store.Viewer, phase string, report playbackReport,
) error {
if s.store == nil {
return fmt.Errorf("no store for viewer playback")
}
// The pool's own tracer times this; nothing extra is recorded here.
position := max64(report.PositionMs, 0) * ticksPerMillisecond
runtime := max64(report.DurationMs, 0) * ticksPerMillisecond
state := store.ViewerState{
ItemID: report.ItemID,
PositionTicks: position,
RuntimeTicks: runtime,
}
// Only a stop can complete a title. A progress report crossing the threshold is
// somebody still watching the closing minutes, and marking it played there would take
// the episode out of Continue Watching underneath them.
if phase == "stopped" {
state.Played = store.PlayedFromPosition(position, runtime)
}
return s.store.RecordViewerPlayback(ctx, viewer.ID, state)
}
// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache
// so Continue Watching reflects the new position on the next home load.
func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -826,12 +915,24 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
"play_session_id", clientLogValue(report.PlaySessionID),
)
err := s.emby.ReportPlayback(
timing.WithLabel(r.Context(), "emby.report"),
credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
)
// Who is watching decides where this goes, and it is the only place that decision is
// made for progress. A shadow viewer's evening is Memby's: nothing below reaches
// /Sessions/Playing, so the Emby account lending them the library never learns what
// they watched or how far they got.
viewer := s.activeViewer(r.Context(), sess, r)
log = log.With("viewer", viewer.ID)
var err error
if viewer.IsMain() {
err = s.emby.ReportPlayback(
timing.WithLabel(r.Context(), "emby.report"),
credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
)
} else {
err = s.recordShadowPlayback(r.Context(), viewer, phase, report)
}
if err != nil {
log.Warn("playback report failed", "phase", phase, "error", err)
// Progress is advisory and another reading follows in ten seconds. A final stop is
@@ -876,7 +977,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
if phase == "stopped" {
invalidate := timing.Start(r.Context(), "invalidate")
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
if err := s.cache.InvalidateUser(r.Context(), viewer.ID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
invalidate()
@@ -891,10 +992,10 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
// episode stops here rather than at the durable insert four upstream calls later;
// the feature check is a cached read; and only then is anything asked of Emby.
if shouldAutoFollowShow(phase, report.PositionMs, report.DurationMs) &&
s.followChecks.claim(sess.EmbyUserID, report.ItemID) &&
s.followChecks.claim(viewer.ID, report.ItemID) &&
s.featureEnabled(r.Context(), featureAutomaticMyShows) {
follow := timing.Start(r.Context(), "autofollow")
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, report.ItemID)
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, viewer, report.ItemID)
follow()
}
writeJSON(w, http.StatusOK, response)
@@ -907,7 +1008,12 @@ 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 {
// The Emby credential reads the catalogue; the viewer owns the list it is written to.
// Following a show is a Memby preference and belongs to the person, so a shadow viewer
// finishing an episode fills their own My Shows rather than the account's.
func (s *Server) autoFollowContinuingShow(
ctx context.Context, sess store.Session, viewer store.Viewer, episodeID string,
) string {
if s.sonarr == nil || s.store == nil {
return ""
}
@@ -949,7 +1055,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
ItemID: episode.SeriesID, Title: seriesItem.Name, Year: seriesItem.ProductionYear,
ImageTag: seriesItem.ImageTags["Primary"],
}
inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show)
inserted, err := s.store.SaveUserShowIfAbsent(ctx, viewer.ID, show)
if err != nil {
s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
return ""
@@ -957,7 +1063,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
if !inserted {
return ""
}
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
prefs, err := s.store.NotificationPreferences(ctx, viewer.ID)
if err != nil {
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
return ""
@@ -965,8 +1071,8 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
notification := notify.Notification{
Kind: "auto-follow",
Source: notifySourceAutoFollow,
UserID: sess.EmbyUserID,
Username: sess.Username,
UserID: viewer.ID,
Username: viewer.Name,
Title: "Added to My Shows",
Body: seriesItem.Name + " was added because you started watching it and it is still continuing.",
ItemID: episode.SeriesID,