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), ) 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 }