0.2.82
This commit is contained in:
+122
-16
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user