2026-07-27 08:16:20 +12:00
package api
import (
"context"
"encoding/json"
2026-08-02 22:10:19 +12:00
"fmt"
2026-07-27 08:16:20 +12:00
"net/http"
"net/url"
2026-07-27 21:06:51 +12:00
"strconv"
2026-07-27 08:16:20 +12:00
"strings"
2026-08-14 11:47:32 +12:00
"time"
2026-07-27 08:16:20 +12:00
"github.com/ponzischeme89/memby/server/internal/emby"
2026-08-14 11:47:32 +12:00
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
2026-07-27 08:16:20 +12:00
"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-08-09 08:25:50 +12:00
// 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"`
2026-08-07 10:44:17 +12:00
// 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"`
2026-08-09 08:25:50 +12:00
// 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"`
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"`
2026-07-27 08:16:20 +12:00
}
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-16 12:13:51 +12:00
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"`
2026-08-11 15:18:26 +12:00
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"`
2026-07-27 08:16:20 +12:00
}
// 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
}
2026-07-27 08:16:20 +12:00
}
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" )
2026-07-27 08:16:20 +12:00
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
}
}
2026-08-14 11:47:32 +12:00
negotiationStarted := time . Now ()
forceTranscode := queryBool ( r , "forceTranscode" )
2026-07-29 15:26:27 +12:00
subtitles , mediaSourceID , playSessionID , negotiatedURL , playMethod := s . playbackSubtitles (
ctx , cred , target . ID , target . UserData . PlaybackPositionTicks , subtitleIndex , "" ,
2026-08-14 11:47:32 +12:00
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 ),
2026-08-14 11:47:32 +12:00
"media_source_id" , mediaSourceID ,
"play_session_id" , clientLogValue ( playSessionID ),
"force_transcode" , forceTranscode ,
"negotiation_duration" , time . Since ( negotiationStarted ). Round ( time . Millisecond ),
2026-08-06 22:33:56 +12:00
)
2026-07-27 08:16:20 +12:00
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-08-09 08:25:50 +12:00
SubtitleFixAvailable : s . subtitleFixAvailable ( subtitles ),
2026-08-07 10:44:17 +12:00
TrickplayAvailable : s . trickplayEnabled ( ctx ),
SkipIntroAvailable : s . skipIntroEnabled ( ctx ),
2026-08-09 08:25:50 +12:00
EndCreditsAvailable : s . endCreditsEnabled ( ctx ),
2026-07-27 08:16:20 +12:00
})
}
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
}
2026-07-27 08:16:20 +12:00
// 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" },
2026-07-27 08:16:20 +12:00
"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" },
2026-07-27 08:16:20 +12:00
"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.
2026-08-16 12:13:51 +12:00
//
// 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.
2026-07-29 15:26:27 +12:00
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-16 12:13:51 +12:00
// 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
}
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-11 15:18:26 +12:00
Item : raw ,
Title : title ,
2026-08-16 12:13:51 +12:00
StreamReady : true ,
2026-08-11 15:18:26 +12:00
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 ),
2026-07-29 15:26:27 +12:00
})
}
2026-08-16 12:13:51 +12:00
// 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
}
}
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 ) {
2026-08-14 11:47:32 +12:00
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" )
2026-07-29 15:26:27 +12:00
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-14 11:47:32 +12:00
log . Warn ( "Emby playback negotiation failed" , "duration" , time . Since ( started ). Round ( time . Millisecond ), "error" , err )
2026-07-29 15:26:27 +12:00
return [] playableSubtitle {}, itemID , "" , "" , "DirectPlay"
}
if len ( info . MediaSources ) == 0 {
2026-08-14 11:47:32 +12:00
log . Warn ( "Emby playback negotiation returned no media sources" , "duration" , time . Since ( started ). Round ( time . Millisecond ), "play_session_id" , info . PlaySessionID )
2026-07-29 15:26:27 +12:00
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-09 08:25:50 +12:00
// 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 ))
2026-08-02 22:10:19 +12:00
delivery , playMethod := selectPlaybackDelivery ( source , forceTranscode || subtitleIndex != nil )
2026-08-14 11:47:32 +12:00
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 )
2026-08-02 22:10:19 +12:00
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
}
2026-08-14 11:47:32 +12:00
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"
}
}
2026-08-02 22:10:19 +12:00
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
2026-08-10 12:23:23 +12:00
case "audio_passthrough_v1" :
capabilities . AudioProfileV1 = true
2026-08-02 22:10:19 +12:00
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 ) {
2026-08-10 12:23:23 +12:00
if parseAudioCapability ( value , capabilities ) {
return
}
2026-08-02 22:10:19 +12:00
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 ,
)
}
2026-08-10 12:23:23 +12:00
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
}
}
2026-08-02 22:10:19 +12:00
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
}
2026-07-27 08:16:20 +12:00
// 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-08-14 11:47:32 +12:00
"media_source_id" , clientLogValue ( report . MediaSourceID ),
"play_session_id" , clientLogValue ( report . PlaySessionID ),
2026-08-06 22:33:56 +12:00
)
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 ,
)
2026-07-27 08:16:20 +12:00
if err != nil {
2026-08-06 22:33:56 +12:00
log . Warn ( "playback report failed" , "phase" , phase , "error" , err )
2026-08-11 15:18:26 +12:00
// 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
}
2026-08-06 22:33:56 +12:00
}
// 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 ),
2026-08-14 11:47:32 +12:00
"event_name" , clientLogValue ( report . EventName ),
2026-08-06 22:33:56 +12:00
)
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 ,
2026-08-14 11:47:32 +12:00
"event_name" , clientLogValue ( report . EventName ),
2026-08-06 22:33:56 +12:00
)
2026-07-27 08:16:20 +12:00
}
2026-08-15 09:23:26 +12:00
// 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 ))
2026-07-27 08:16:20 +12:00
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-07-27 08:16:20 +12:00
}
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-07-27 08:16:20 +12:00
}
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 ""
}
2026-08-09 08:25:50 +12:00
sonarrSeries , err := s . sonarrSeriesCatalogue ( ctx )
2026-08-02 22:10:19 +12:00
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 ""
2026-07-27 08:16:20 +12:00
}
func max64 ( v , floor int64 ) int64 {
if v < floor {
return floor
}
return v
}