Files
memby/server/internal/api/playback.go
T
2026-08-19 18:08:00 +12:00

997 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
}
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"`
}
// 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)
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, 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
}
}
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),
)
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),
})
}
// 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.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
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
}
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"},
"EnableImageTypes": {"Primary,Thumb"},
})
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
}
// 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) {
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),
})
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),
})
}
// 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))
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
}
// 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),
)
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,
)
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(), sess.EmbyUserID); 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(sess.EmbyUserID, report.ItemID) &&
s.featureEnabled(r.Context(), featureAutomaticMyShows) {
follow := timing.Start(r.Context(), "autofollow")
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, 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
}
func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Session, 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, sess.EmbyUserID, show)
if err != nil {
s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
return ""
}
if !inserted {
return ""
}
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
if err != nil {
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
return ""
}
notification := notify.Notification{
Kind: "auto-follow",
Source: notifySourceAutoFollow,
UserID: sess.EmbyUserID,
Username: sess.Username,
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
}