Files
memby/server/internal/api/playback.go
T

776 lines
27 KiB
Go
Raw Normal View History

package api
import (
"context"
"encoding/json"
2026-08-02 22:10:19 +12:00
"fmt"
"net/http"
"net/url"
2026-07-27 21:06:51 +12:00
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
const ticksPerMillisecond = 10_000
type playbackResponse struct {
2026-08-02 22:10:19 +12:00
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"`
2026-08-06 22:33:56 +12:00
// 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"`
2026-07-29 15:26:27 +12:00
}
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 {
2026-07-29 15:26:27 +12:00
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
2026-08-02 22:10:19 +12:00
DurationMs int64 `json:"durationMs,omitempty"`
2026-07-29 15:26:27 +12:00
IsPaused bool `json:"isPaused"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
EventName string `json:"eventName,omitempty"`
}
2026-08-02 22:10:19 +12:00
type playbackReportResponse struct {
AutoFollowedShowTitle string `json:"autoFollowedShowTitle,omitempty"`
}
2026-07-29 15:26:27 +12:00
// 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 {
2026-08-06 22:33:56 +12:00
Item json.RawMessage `json:"item"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
SubtitlesEnabled bool `json:"subtitlesEnabled"`
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
}
// handlePlayback resolves what to actually play.
//
// 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)
2026-07-27 21:06:51 +12:00
item, hinted := playbackHint(r, itemID)
if !hinted {
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
if err != nil {
2026-08-06 22:33:56 +12:00
s.writeUpstreamError(ctx, w, err, "could not load the item")
2026-07-27 21:06:51 +12:00
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 {
2026-08-06 22:33:56 +12:00
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
}
}
2026-07-29 15:26:27 +12:00
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
}
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
2026-08-02 22:10:19 +12:00
queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess),
2026-07-29 15:26:27 +12:00
)
streamURL := s.emby.StreamURL(cred, target.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
2026-08-06 22:33:56 +12:00
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)
}
2026-08-02 22:10:19 +12:00
playbackPolicy := store.DefaultPlaybackPolicy()
if s.store != nil {
if policy, policyErr := s.store.PlaybackPolicy(ctx); policyErr == nil {
playbackPolicy = policy
} else {
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).Warn("playback policy unavailable", "error", policyErr)
2026-08-02 22:10:19 +12:00
}
}
2026-08-06 22:33:56 +12:00
// The one event that says what somebody actually tried to watch. It is logged even
// though the request line already records the route, because the route carries an
// item id and nobody can read an item id.
s.playbackTitles.remember(target.ID, title)
s.loggerFor(ctx).Info("playback requested",
"title", title,
"item", target.ID,
"type", target.Type,
"play_method", clientLogValue(playMethod),
"resume", millisecondDuration(target.UserData.PlaybackPositionTicks/ticksPerMillisecond),
"runtime", millisecondDuration(target.RunTimeTicks/ticksPerMillisecond),
"subtitles", len(subtitles),
"subtitle_track", clientLogValue(selectedSubtitleID),
"subtitle_language", clientLogValue(subtitleLanguage),
)
writeJSON(w, http.StatusOK, playbackResponse{
2026-08-06 22:33:56 +12:00
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),
})
}
2026-07-27 21:06:51 +12:00
// 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"},
2026-08-02 22:10:19 +12:00
"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"},
2026-08-02 22:10:19 +12:00
"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
}
2026-08-02 22:10:19 +12:00
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)
}
2026-07-29 15:26:27 +12:00
// 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.
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 {
2026-08-06 22:33:56 +12:00
s.writeUpstreamError(ctx, w, err, "could not load the item")
2026-07-29 15:26:27 +12:00
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 {
2026-08-06 22:33:56 +12:00
s.writeUpstreamError(ctx, w, err, "could not load the next episode")
2026-07-29 15:26:27 +12:00
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
}
2026-08-02 22:10:19 +12:00
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false,
s.effectivePlaybackCapabilities(ctx, sess),
2026-07-29 15:26:27 +12:00
)
2026-08-02 22:10:19 +12:00
streamURL := s.emby.StreamURL(cred, next.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
2026-08-06 22:33:56 +12:00
// 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),
)
2026-07-29 15:26:27 +12:00
writeJSON(w, http.StatusOK, nextEpisodeResponse{
2026-08-06 22:33:56 +12:00
Item: raw,
Title: title,
URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
SubtitlesEnabled: subtitlesEnabled,
SelectedSubtitleID: selectedSubtitleID,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
2026-07-29 15:26:27 +12:00
})
}
func (s *Server) playbackSubtitles(
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
2026-08-02 22:10:19 +12:00
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
capabilities emby.PlaybackCapabilities,
2026-07-29 15:26:27 +12:00
) ([]playableSubtitle, string, string, string, string) {
info, err := s.emby.PlaybackInfo(
2026-08-02 22:10:19 +12:00
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
capabilities,
2026-07-29 15:26:27 +12:00
)
if err != nil {
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
2026-07-29 15:26:27 +12:00
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
}
if len(info.MediaSources) == 0 {
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,
})
}
2026-08-02 22:10:19 +12:00
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
if delivery != "" {
delivery = s.emby.DeliveryURL(cred, delivery)
2026-07-29 15:26:27 +12:00
}
2026-08-02 22:10:19 +12:00
return out, source.ID, info.PlaySessionID, delivery, playMethod
}
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
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) {
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 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
2026-07-29 15:26:27 +12:00
}
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
}
2026-08-06 22:33:56 +12:00
log := s.loggerFor(r.Context()).With(
"title", s.playbackTitles.name(report.ItemID),
"item", report.ItemID,
)
2026-07-29 15:26:27 +12:00
err := s.emby.ReportPlayback(
r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
)
if err != nil {
// A dropped progress report is not worth failing playback over; log and accept.
2026-08-06 22:33:56 +12:00
log.Warn("playback report failed", "phase", phase, "error", err)
}
// Start and stop are the shape of an evening's viewing and belong in the normal log.
// Progress arrives every ten seconds for the length of a film, so it is DEBUG: useful
// when investigating one stall, ruinous as a default.
switch phase {
case "started":
log.Info("playback started",
"position", millisecondDuration(report.PositionMs),
"play_method", clientLogValue(report.PlayMethod),
)
case "stopped":
log.Info("playback stopped",
"position", millisecondDuration(report.PositionMs),
"runtime", millisecondDuration(report.DurationMs),
"watched", watchedPercent(report.PositionMs, report.DurationMs),
)
default:
log.Debug("playback progress",
"position", millisecondDuration(report.PositionMs),
"paused", report.IsPaused,
)
}
if phase == "stopped" {
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
2026-08-06 22:33:56 +12:00
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
2026-08-02 22:10:19 +12:00
// 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.
}
2026-08-02 22:10:19 +12:00
response := playbackReportResponse{}
if shouldAutoFollowShow(phase, report.PositionMs, report.DurationMs) &&
s.featureEnabled(r.Context(), featureAutomaticMyShows) {
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, report.ItemID)
}
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 {
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).Warn("auto-follow episode lookup failed", "error", err)
2026-08-02 22:10:19 +12:00
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 {
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).Warn("auto-follow series lookup failed", "error", err)
2026-08-02 22:10:19 +12:00
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.sonarr.Series(ctx)
if err != nil {
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
2026-08-02 22:10:19 +12:00
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 {
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
2026-08-02 22:10:19 +12:00
return ""
}
if !inserted {
return ""
}
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
if err != nil {
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
2026-08-02 22:10:19 +12:00
return ""
}
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
_ = s.store.UpsertNotification(
ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow",
episode.SeriesID, "Added to My Shows",
seriesItem.Name+" was added because you started watching it and it is still continuing.", nil,
)
return seriesItem.Name
}
return ""
}
func max64(v, floor int64) int64 {
if v < floor {
return floor
}
return v
}