1172 lines
46 KiB
Go
1172 lines
46 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||
"github.com/ponzischeme89/memby/server/internal/store"
|
||
"github.com/ponzischeme89/memby/server/internal/timing"
|
||
)
|
||
|
||
const ticksPerMillisecond = 10_000
|
||
|
||
type playbackResponse struct {
|
||
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"`
|
||
// 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"`
|
||
// Whether at least one subtitle on this title can be checked against another readable
|
||
// text track. Like the download flag, this rides on the playback response because only
|
||
// the subtitle drop-up needs it, and defaults to false for older gateways on the client.
|
||
SubtitleFixAvailable bool `json:"subtitleFixAvailable"`
|
||
// Whether it is worth asking this gateway for seek previews. Only the answer rides
|
||
// here; the manifest itself does not, because reading it costs a round trip to Emby
|
||
// and this response is the one thing standing between a Play press and a decoder
|
||
// starting. The television asks for the manifest once the first frame is up.
|
||
TrickplayAvailable bool `json:"trickplayAvailable"`
|
||
// Whether it is worth asking this gateway where the title sequence is. Only the answer
|
||
// rides here, for the same reason the previews' does: reading the markers costs a round
|
||
// trip to Emby, and nothing about a skip button is needed before the first frame. The
|
||
// television asks for the segment itself once playback has settled.
|
||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||
// Whether it is worth asking where the closing credits begin. Same reasoning again, and
|
||
// deliberately a second boolean rather than a reuse of SkipIntroAvailable: the two are
|
||
// separate features with separate switches, and a house that has turned the skip button
|
||
// off has not asked to lose the credits pane with it.
|
||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||
// Whether Sonarr can name when this continuing show's next episode airs. Rides here for
|
||
// the same reason the other availability flags do: the player asks once at the start of
|
||
// playback and the notice itself takes no further request. Absent (all fields blank)
|
||
// means either the show isn't Sonarr-tracked, isn't continuing, or has no scheduled
|
||
// airing to report — the client shows nothing in every one of those cases.
|
||
NextAiringAvailable bool `json:"nextAiringAvailable"`
|
||
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
|
||
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
|
||
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
|
||
}
|
||
|
||
type playableSubtitle struct {
|
||
ID string `json:"id"`
|
||
URL string `json:"url"`
|
||
MimeType string `json:"mimeType"`
|
||
Language string `json:"language,omitempty"`
|
||
Label string `json:"label,omitempty"`
|
||
IsDefault bool `json:"isDefault"`
|
||
IsForced bool `json:"isForced"`
|
||
IsHearingImpaired bool `json:"isHearingImpaired"`
|
||
DeliveryMethod string `json:"deliveryMethod"`
|
||
Codec string `json:"codec,omitempty"`
|
||
}
|
||
|
||
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"`
|
||
PlayMethod string `json:"playMethod"`
|
||
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.
|
||
type nextEpisodeResponse struct {
|
||
Item json.RawMessage `json:"item"`
|
||
Title string `json:"title"`
|
||
// StreamReady says whether this answer carries a negotiated stream. A client asking
|
||
// with `stream=0` gets identity, artwork and runtime and nothing that touches Emby's
|
||
// PlaybackInfo — see [handleNextEpisode] for why that separation exists.
|
||
StreamReady bool `json:"streamReady"`
|
||
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"`
|
||
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
|
||
TrickplayAvailable bool `json:"trickplayAvailable"`
|
||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||
NextAiringAvailable bool `json:"nextAiringAvailable"`
|
||
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
|
||
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
|
||
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
|
||
}
|
||
|
||
// handlePlayback resolves what to actually play.
|
||
//
|
||
// This is logic the TV used to carry: a series resolves to its next-up episode (falling
|
||
// back to the first), and the returned URL points straight at Emby so the video stream
|
||
// never traverses the gateway.
|
||
func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||
ctx := r.Context()
|
||
itemID := r.PathValue("id")
|
||
if itemID == "" {
|
||
writeError(w, http.StatusBadRequest, "item id is required")
|
||
return
|
||
}
|
||
cred := credentials(sess)
|
||
viewer := viewerOf(ctx, sess)
|
||
|
||
item, hinted := playbackHint(r, itemID)
|
||
if !hinted {
|
||
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
|
||
if err != nil {
|
||
s.writeUpstreamError(ctx, w, err, "could not load the item")
|
||
return
|
||
}
|
||
item, err = emby.Summarise(raw)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadGateway, "unreadable item from emby")
|
||
return
|
||
}
|
||
}
|
||
|
||
target := item
|
||
title := item.Name
|
||
|
||
if strings.EqualFold(item.Type, "Series") {
|
||
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
|
||
}
|
||
if episode == nil {
|
||
writeError(w, http.StatusNotFound, "no episodes found for this series")
|
||
return
|
||
}
|
||
target = *episode
|
||
if episode.Name != "" {
|
||
title = item.Name + " – " + episode.Name
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
subtitleIndex = &parsed
|
||
}
|
||
}
|
||
negotiationStarted := time.Now()
|
||
forceTranscode := queryBool(r, "forceTranscode")
|
||
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
|
||
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
|
||
forceTranscode, s.effectivePlaybackCapabilities(ctx, sess),
|
||
)
|
||
streamURL := s.emby.StreamURL(cred, target.ID)
|
||
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.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),
|
||
"media_source_id", mediaSourceID,
|
||
"play_session_id", clientLogValue(playSessionID),
|
||
"force_transcode", forceTranscode,
|
||
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
|
||
)
|
||
|
||
// Starting to watch it retires the REQUEST READY pin — the request journey is complete,
|
||
// and from here the card is ordinary Continue Watching. Best-effort and detached: the
|
||
// viewer is about to watch something regardless of whether this write lands, and the
|
||
// age-out sweep is the backstop. itemID is the id the card opened (the series id for a
|
||
// series request); target.ID is the resolved movie or episode.
|
||
if s.store != nil {
|
||
detached := context.WithoutCancel(ctx)
|
||
go func() {
|
||
cleared, err := s.store.ClearRequestReadySurfaced(detached, sess.EmbyUserID, itemID, target.ID)
|
||
if err != nil {
|
||
s.loggerFor(detached).Warn("request ready pin not cleared", "error", err)
|
||
return
|
||
}
|
||
if cleared > 0 {
|
||
s.invalidateHomeFor(detached, sess.EmbyUserID)
|
||
}
|
||
}()
|
||
}
|
||
|
||
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
|
||
s.nextAiringFieldsFor(ctx, target)
|
||
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,
|
||
SubtitlesEnabled: subtitlesEnabled,
|
||
SelectedSubtitleID: selectedSubtitleID,
|
||
MediaSourceID: mediaSourceID,
|
||
PlaySessionID: playSessionID,
|
||
PlayMethod: playMethod,
|
||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||
SubtitleFixAvailable: s.subtitleFixAvailable(subtitles),
|
||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||
NextAiringAvailable: nextAiringAvailable,
|
||
NextAiringLabel: nextAiringLabel,
|
||
NextAiringDayLabel: nextAiringDayLabel,
|
||
NextAiringEpisodeCode: nextAiringCode,
|
||
})
|
||
}
|
||
|
||
// Current clients already know the selected item's type, title and cached resume point.
|
||
// Accepting those as hints removes one serial Emby request from every launch. Older
|
||
// clients omit them and retain the authoritative lookup above.
|
||
func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
|
||
itemType := strings.TrimSpace(r.URL.Query().Get("type"))
|
||
switch {
|
||
case strings.EqualFold(itemType, "Movie"):
|
||
itemType = "Movie"
|
||
case strings.EqualFold(itemType, "Episode"):
|
||
itemType = "Episode"
|
||
case strings.EqualFold(itemType, "Series"):
|
||
itemType = "Series"
|
||
default:
|
||
return emby.Summary{}, false
|
||
}
|
||
resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64)
|
||
item := emby.Summary{
|
||
ID: itemID,
|
||
Name: strings.TrimSpace(r.URL.Query().Get("title")),
|
||
Type: itemType,
|
||
}
|
||
item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond
|
||
return item, true
|
||
}
|
||
|
||
// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
|
||
// 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"},
|
||
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
|
||
"EnableUserData": {"true"},
|
||
})
|
||
if err == nil && len(nextUp.Items) > 0 {
|
||
if summary, err := emby.Summarise(nextUp.Items[0]); err == nil {
|
||
return &summary, nil
|
||
}
|
||
}
|
||
|
||
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
|
||
"Limit": {"1"},
|
||
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
|
||
"EnableUserData": {"true"},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(episodes.Items) == 0 {
|
||
return nil, nil
|
||
}
|
||
summary, err := emby.Summarise(episodes.Items[0])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &summary, nil
|
||
}
|
||
|
||
// nextAiringFieldsFor answers the current episode's own show's next airing, never the
|
||
// episode being played itself. It only ever asks Sonarr about a series that is actually
|
||
// known — a hinted playback response that skipped the Emby lookup and so carries no
|
||
// SeriesName simply gets nothing, the same silent degradation every other availability
|
||
// flag here takes.
|
||
func (s *Server) nextAiringFieldsFor(ctx context.Context, item emby.Summary) (available bool, label, dayLabel, code string) {
|
||
if !strings.EqualFold(item.Type, "Episode") {
|
||
return false, "", "", ""
|
||
}
|
||
info, ok := s.nextAiringInfoFor(ctx, item.SeriesName)
|
||
if !ok {
|
||
return false, "", "", ""
|
||
}
|
||
return true, info.Label, info.DayLabel, info.EpisodeCode
|
||
}
|
||
|
||
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.
|
||
//
|
||
// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an
|
||
// unreadable series all come back as 404 and the client simply shows no banner.
|
||
//
|
||
// It answers in two shapes, and the separation is the whole reason auto-advance stopped
|
||
// showing a viewer the machinery. `stream=0` asks for **identity only** — the episode's
|
||
// item JSON, its title and its artwork — and touches nothing but the episode list. The
|
||
// full answer additionally negotiates PlaybackInfo, which mints a play session and, on a
|
||
// title that has to be transcoded, a transcode session at Emby.
|
||
//
|
||
// The television asks for the cheap shape when the episode it is watching *starts*, which
|
||
// is where the banner's picture and wording come from, and for the full one shortly before
|
||
// the hand-over. Doing both at once is what the player used to do, and it was wrong twice:
|
||
// a session negotiated forty minutes early has long since been torn down by the time it is
|
||
// used — so the transition failed, retried, and narrated all of it to the viewer — and the
|
||
// transcode it may have started sat there being paid for by a viewer who had not yet
|
||
// decided to watch it.
|
||
func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||
ctx := r.Context()
|
||
itemID := r.PathValue("id")
|
||
if itemID == "" {
|
||
writeError(w, http.StatusBadRequest, "item id is required")
|
||
return
|
||
}
|
||
cred := credentials(sess)
|
||
|
||
// The client already knows which series it launched, so accepting it as a hint keeps
|
||
// this off Emby for one round trip. Older clients omit it and we look it up.
|
||
seriesID := strings.TrimSpace(r.URL.Query().Get("seriesId"))
|
||
if seriesID == "" {
|
||
raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId")
|
||
if err != nil {
|
||
s.writeUpstreamError(ctx, w, err, "could not load the item")
|
||
return
|
||
}
|
||
var parsed struct {
|
||
SeriesID string `json:"SeriesId"`
|
||
}
|
||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
writeError(w, http.StatusBadGateway, "unreadable item from emby")
|
||
return
|
||
}
|
||
seriesID = parsed.SeriesID
|
||
}
|
||
if seriesID == "" {
|
||
writeError(w, http.StatusNotFound, "this item is not part of a series")
|
||
return
|
||
}
|
||
|
||
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
|
||
"AdjacentTo": {itemID},
|
||
"Fields": {"RunTimeTicks,Overview,SeriesName"},
|
||
"EnableUserData": {"true"},
|
||
// Logo alongside the row's own artwork, or the next-up banner has nothing to
|
||
// draw a title treatment from — Emby folds the series' logo into an episode's
|
||
// ParentLogoItemId/ParentLogoImageTag only for image types this request asks for.
|
||
"EnableImageTypes": {"Primary,Thumb,Logo"},
|
||
})
|
||
if err != nil {
|
||
s.writeUpstreamError(ctx, w, err, "could not load the next episode")
|
||
return
|
||
}
|
||
|
||
raw, next, ok := episodeAfter(episodes.Items, itemID)
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "no episode follows this one")
|
||
return
|
||
}
|
||
|
||
title := next.Name
|
||
if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
|
||
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.
|
||
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
|
||
s.nextAiringFieldsFor(ctx, next)
|
||
if !nextEpisodeWantsStream(r) {
|
||
s.playbackTitles.remember(next.ID, title)
|
||
s.loggerFor(ctx).Debug("next episode identified",
|
||
"title", title, "item", next.ID, "after_item", itemID)
|
||
writeJSON(w, http.StatusOK, nextEpisodeResponse{
|
||
Item: raw,
|
||
Title: title,
|
||
StreamReady: false,
|
||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||
Subtitles: []playableSubtitle{},
|
||
SubtitlesEnabled: true,
|
||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||
NextAiringAvailable: nextAiringAvailable,
|
||
NextAiringLabel: nextAiringLabel,
|
||
NextAiringDayLabel: nextAiringDayLabel,
|
||
NextAiringEpisodeCode: nextAiringCode,
|
||
})
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
// 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,
|
||
StreamReady: true,
|
||
URL: streamURL,
|
||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||
Subtitles: subtitles,
|
||
SubtitlesEnabled: subtitlesEnabled,
|
||
SelectedSubtitleID: selectedSubtitleID,
|
||
MediaSourceID: mediaSourceID,
|
||
PlaySessionID: playSessionID,
|
||
PlayMethod: playMethod,
|
||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||
NextAiringAvailable: nextAiringAvailable,
|
||
NextAiringLabel: nextAiringLabel,
|
||
NextAiringDayLabel: nextAiringDayLabel,
|
||
NextAiringEpisodeCode: nextAiringCode,
|
||
})
|
||
}
|
||
|
||
// nextEpisodeWantsStream reads the client's `stream` parameter, and **defaults to yes**.
|
||
// An app built before the two-phase lookup existed sends nothing and expects the whole
|
||
// answer; reading a missing parameter as "identity only" would hand every one of those
|
||
// televisions a next-up banner with no stream behind it.
|
||
func nextEpisodeWantsStream(r *http.Request) bool {
|
||
switch strings.TrimSpace(strings.ToLower(r.URL.Query().Get("stream"))) {
|
||
case "0", "false", "no":
|
||
return false
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
func (s *Server) playbackSubtitles(
|
||
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
|
||
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
|
||
capabilities emby.PlaybackCapabilities,
|
||
) ([]playableSubtitle, string, string, string, string) {
|
||
started := time.Now()
|
||
log := s.loggerFor(ctx).With("item", itemID, "force_transcode", forceTranscode,
|
||
"subtitle_index", subtitleIndex != nil, "resume", millisecondDuration(startTicks/ticksPerMillisecond),
|
||
"emby_client", s.cfg.ClientName, "device", cred.DeviceName, "device_id", cred.DeviceID,
|
||
"client_version", cred.ClientVersion)
|
||
log.Log(ctx, serverlogging.LevelTrace, "Emby playback negotiation started")
|
||
info, err := s.emby.PlaybackInfo(
|
||
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
|
||
capabilities,
|
||
)
|
||
if err != nil {
|
||
log.Warn("Emby playback negotiation failed", "duration", time.Since(started).Round(time.Millisecond), "error", err)
|
||
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
|
||
}
|
||
if len(info.MediaSources) == 0 {
|
||
log.Warn("Emby playback negotiation returned no media sources", "duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID)
|
||
return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
|
||
}
|
||
out := make([]playableSubtitle, 0)
|
||
seen := make(map[string]bool)
|
||
source := info.MediaSources[0]
|
||
for _, stream := range source.MediaStreams {
|
||
if !strings.EqualFold(stream.Type, "Subtitle") || stream.Index < 0 {
|
||
continue
|
||
}
|
||
method := stream.DeliveryMethod
|
||
if method == "" {
|
||
if stream.IsTextSubtitleStream {
|
||
method = "External"
|
||
} else {
|
||
method = "Encode"
|
||
}
|
||
}
|
||
delivery := ""
|
||
mimeType := subtitleMIME(stream.Codec, stream.DeliveryURL)
|
||
if strings.EqualFold(method, "External") && mimeType != "" {
|
||
if stream.DeliveryURL != "" {
|
||
delivery = s.emby.DeliveryURL(cred, stream.DeliveryURL)
|
||
} else {
|
||
delivery = s.emby.SubtitleURL(cred, itemID, source.ID, stream.Index, subtitleExtension(stream.Codec))
|
||
}
|
||
}
|
||
key := strconv.Itoa(stream.Index)
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
label := strings.TrimSpace(stream.DisplayTitle)
|
||
if label == "" {
|
||
label = strings.TrimSpace(stream.Title)
|
||
}
|
||
out = append(out, playableSubtitle{
|
||
ID: strconv.Itoa(stream.Index),
|
||
URL: delivery,
|
||
MimeType: mimeType,
|
||
Language: strings.TrimSpace(stream.Language),
|
||
Label: label,
|
||
IsDefault: stream.IsDefault,
|
||
IsForced: stream.IsForced,
|
||
IsHearingImpaired: stream.IsHearingImpaired ||
|
||
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "sdh") ||
|
||
strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "hearing"),
|
||
DeliveryMethod: method,
|
||
Codec: stream.Codec,
|
||
})
|
||
}
|
||
// Anything the gateway fetched itself joins the list here, so a subtitle downloaded
|
||
// from a provider that cannot write beside the media file is an ordinary track on
|
||
// every later playback — not something that exists only in the response to the
|
||
// download that produced it.
|
||
out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID))
|
||
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
|
||
log.Debug("Emby playback source selected",
|
||
"duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID,
|
||
"media_source_id", source.ID, "media_sources", len(info.MediaSources), "subtitles", len(out),
|
||
"play_method", playMethod, "selection_reason", playbackSelectionReason(source, forceTranscode || subtitleIndex != nil),
|
||
"supports_direct_play", source.SupportsDirectPlay, "supports_direct_stream", source.SupportsDirectStream,
|
||
"supports_transcoding", source.SupportsTranscoding)
|
||
if delivery != "" {
|
||
delivery = s.emby.DeliveryURL(cred, delivery)
|
||
}
|
||
return out, source.ID, info.PlaySessionID, delivery, playMethod
|
||
}
|
||
|
||
func playbackSelectionReason(source emby.MediaSourceInfo, forceTranscode bool) string {
|
||
switch {
|
||
case forceTranscode && source.TranscodingURL != "":
|
||
return "forced by viewer or burned-in subtitle"
|
||
case source.SupportsDirectPlay:
|
||
return "source supports direct play"
|
||
case source.SupportsDirectStream && source.DirectStreamURL != "":
|
||
return "direct play unavailable; source supports direct stream"
|
||
case source.SupportsTranscoding && source.TranscodingURL != "":
|
||
return "source requires transcoding"
|
||
case source.DirectStreamURL != "":
|
||
return "fallback direct-stream URL available"
|
||
case source.TranscodingURL != "":
|
||
return "fallback transcode URL available"
|
||
default:
|
||
return "Emby supplied no alternate delivery URL"
|
||
}
|
||
}
|
||
|
||
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
|
||
case "audio_passthrough_v1":
|
||
capabilities.AudioProfileV1 = 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) {
|
||
if parseAudioCapability(value, capabilities) {
|
||
return
|
||
}
|
||
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 parseAudioCapability(value string, capabilities *emby.PlaybackCapabilities) bool {
|
||
const prefix = "audio_"
|
||
if !strings.HasPrefix(value, prefix) {
|
||
return false
|
||
}
|
||
if strings.HasPrefix(value, "audio_max_channels_") {
|
||
parsed, err := strconv.Atoi(strings.TrimPrefix(value, "audio_max_channels_"))
|
||
if err == nil && parsed >= 2 && parsed <= 8 {
|
||
capabilities.AudioMaxChannels = parsed
|
||
}
|
||
return true
|
||
}
|
||
for _, suffix := range []string{"_passthrough", "_decode"} {
|
||
if !strings.HasSuffix(value, suffix) {
|
||
continue
|
||
}
|
||
codec := strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix)
|
||
if !validAudioCapabilityCodec(codec) {
|
||
return true
|
||
}
|
||
if suffix == "_passthrough" {
|
||
if capabilities.AudioPassthrough == nil {
|
||
capabilities.AudioPassthrough = map[string]bool{}
|
||
}
|
||
capabilities.AudioPassthrough[codec] = true
|
||
} else {
|
||
if capabilities.AudioDecode == nil {
|
||
capabilities.AudioDecode = map[string]bool{}
|
||
}
|
||
capabilities.AudioDecode[codec] = true
|
||
}
|
||
return true
|
||
}
|
||
return true
|
||
}
|
||
|
||
func validAudioCapabilityCodec(codec string) bool {
|
||
switch codec {
|
||
case "ac3", "eac3", "atmos", "dts", "dts_hd", "truehd":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
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 {
|
||
switch strings.ToLower(strings.TrimSpace(codec)) {
|
||
case "subrip":
|
||
return "srt"
|
||
case "webvtt":
|
||
return "vtt"
|
||
case "tx3g":
|
||
return "mov_text"
|
||
default:
|
||
if strings.TrimSpace(codec) == "" {
|
||
return "vtt"
|
||
}
|
||
return strings.ToLower(strings.TrimSpace(codec))
|
||
}
|
||
}
|
||
|
||
func subtitleMIME(codec, delivery string) string {
|
||
value := strings.ToLower(strings.TrimSpace(codec))
|
||
if value == "" {
|
||
path := delivery
|
||
if parsed, err := url.Parse(delivery); err == nil {
|
||
path = parsed.Path
|
||
}
|
||
if dot := strings.LastIndex(path, "."); dot >= 0 {
|
||
value = strings.ToLower(path[dot+1:])
|
||
}
|
||
}
|
||
switch value {
|
||
case "srt", "subrip":
|
||
return "application/x-subrip"
|
||
case "vtt", "webvtt":
|
||
return "text/vtt"
|
||
case "ass", "ssa":
|
||
return "text/x-ssa"
|
||
case "ttml", "dfxp":
|
||
return "application/ttml+xml"
|
||
case "tx3g", "mov_text":
|
||
return "application/x-quicktime-tx3g"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// episodeAfter picks the episode following currentID out of an AdjacentTo result, which
|
||
// Emby returns in running order as [previous, current, next] minus whichever ends do not
|
||
// exist — so the position of the current episode is what identifies the next one, not the
|
||
// length of the list.
|
||
func episodeAfter(items []json.RawMessage, currentID string) (json.RawMessage, emby.Summary, bool) {
|
||
for i, raw := range items {
|
||
summary, err := emby.Summarise(raw)
|
||
if err != nil || summary.ID != currentID {
|
||
continue
|
||
}
|
||
if i+1 >= len(items) {
|
||
return nil, emby.Summary{}, false
|
||
}
|
||
next, err := emby.Summarise(items[i+1])
|
||
if err != nil || next.ID == "" {
|
||
return nil, emby.Summary{}, false
|
||
}
|
||
return items[i+1], next, true
|
||
}
|
||
return nil, emby.Summary{}, false
|
||
}
|
||
|
||
func seriesNameOf(raw json.RawMessage) string {
|
||
var parsed struct {
|
||
SeriesName string `json:"SeriesName"`
|
||
}
|
||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
return ""
|
||
}
|
||
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) {
|
||
phase := r.PathValue("phase")
|
||
switch phase {
|
||
case "started", "progress", "stopped":
|
||
default:
|
||
writeError(w, http.StatusNotFound, "unknown playback phase")
|
||
return
|
||
}
|
||
|
||
var report playbackReport
|
||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&report); err != nil {
|
||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||
return
|
||
}
|
||
if report.ItemID == "" {
|
||
writeError(w, http.StatusBadRequest, "itemId is required")
|
||
return
|
||
}
|
||
|
||
log := s.loggerFor(r.Context()).With(
|
||
"title", s.playbackTitles.name(report.ItemID),
|
||
"item", report.ItemID,
|
||
"media_source_id", clientLogValue(report.MediaSourceID),
|
||
"play_session_id", clientLogValue(report.PlaySessionID),
|
||
)
|
||
|
||
// 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
|
||
// different: the television persists it in WorkManager specifically so an outage
|
||
// cannot lose the final resume position. Returning success here would consume that
|
||
// durable work and disable its bounded backoff.
|
||
if phase == "stopped" {
|
||
s.writeUpstreamError(r.Context(), w, err, "could not report playback stopped")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 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),
|
||
"event_name", clientLogValue(report.EventName),
|
||
)
|
||
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,
|
||
"event_name", clientLogValue(report.EventName),
|
||
)
|
||
}
|
||
|
||
// Credits discovery rides the reports that were already being made. The session key is
|
||
// the play session where the television gave one and the device otherwise, so two
|
||
// televisions playing the same episode count as two streams rather than one.
|
||
s.noteCreditsPlayback(r.Context(), phase, report.ItemID,
|
||
playbackSessionKey(sess.DeviceID, report.PlaySessionID))
|
||
|
||
if phase == "stopped" {
|
||
invalidate := timing.Start(r.Context(), "invalidate")
|
||
if err := s.cache.InvalidateUser(r.Context(), viewer.ID); err != nil {
|
||
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
|
||
}
|
||
invalidate()
|
||
// 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.
|
||
}
|
||
|
||
response := playbackReportResponse{}
|
||
// The order of these three is the whole of what keeps a progress report cheap. The
|
||
// claim is a map lookup and comes first, so every report after the first for this
|
||
// 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(viewer.ID, report.ItemID) &&
|
||
s.featureEnabled(r.Context(), featureAutomaticMyShows) {
|
||
follow := timing.Start(r.Context(), "autofollow")
|
||
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, viewer, report.ItemID)
|
||
follow()
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
rawEpisode, err := s.emby.Item(ctx, credentials(sess), episodeID, "SeriesId")
|
||
if err != nil {
|
||
s.loggerFor(ctx).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.loggerFor(ctx).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.sonarrSeriesCatalogue(ctx)
|
||
if err != nil {
|
||
s.loggerFor(ctx).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, viewer.ID, show)
|
||
if err != nil {
|
||
s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
|
||
return ""
|
||
}
|
||
if !inserted {
|
||
return ""
|
||
}
|
||
prefs, err := s.store.NotificationPreferences(ctx, viewer.ID)
|
||
if err != nil {
|
||
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
|
||
return ""
|
||
}
|
||
notification := notify.Notification{
|
||
Kind: "auto-follow",
|
||
Source: notifySourceAutoFollow,
|
||
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,
|
||
SourceKey: "auto-follow:" + episode.SeriesID,
|
||
Metadata: map[string]any{"series": seriesItem.Name},
|
||
}
|
||
// The show is followed either way — that is the feature — and only the *notice* is
|
||
// conditional. Recording the refusal is what separates "Memby quietly followed this for
|
||
// you" from a bug, which from the viewer's side look the same.
|
||
if !prefs.Enabled {
|
||
s.declineUser(ctx, notification, "this viewer has notifications switched off")
|
||
return ""
|
||
}
|
||
if !s.featureEnabled(ctx, featureMyShowsNotification) {
|
||
s.declineUser(ctx, notification, "the My Shows notification feature is switched off")
|
||
return ""
|
||
}
|
||
s.notifyUser(ctx, notification)
|
||
return seriesItem.Name
|
||
}
|
||
|
||
func max64(v, floor int64) int64 {
|
||
if v < floor {
|
||
return floor
|
||
}
|
||
return v
|
||
}
|