package api import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/store" ) const ticksPerMillisecond = 10_000 type playbackResponse struct { ItemID string `json:"itemId"` Title string `json:"title"` URL string `json:"url"` ResumePositionMs int64 `json:"resumePositionMs"` Subtitles []playableSubtitle `json:"subtitles"` MediaSourceID string `json:"mediaSourceId"` PlaySessionID string `json:"playSessionId"` PlayMethod string `json:"playMethod"` } 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"` IsPaused bool `json:"isPaused"` MediaSourceID string `json:"mediaSourceId"` PlaySessionID string `json:"playSessionId"` PlayMethod string `json:"playMethod"` EventName string `json:"eventName,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"` URL string `json:"url"` ResumePositionMs int64 `json:"resumePositionMs"` Subtitles []playableSubtitle `json:"subtitles"` 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) item, hinted := playbackHint(r, itemID) if !hinted { raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName") if err != nil { s.writeUpstreamError(w, err, "could not load the item") return } item, err = emby.Summarise(raw) if err != nil { writeError(w, http.StatusBadGateway, "unreadable item from emby") return } } target := item title := item.Name if strings.EqualFold(item.Type, "Series") { episode, err := s.firstPlayableEpisode(ctx, cred, item.ID) if err != nil { s.writeUpstreamError(w, err, "could not find an episode to play") return } if episode == nil { writeError(w, http.StatusNotFound, "no episodes found for this series") return } target = *episode if episode.Name != "" { title = item.Name + " – " + episode.Name } } var subtitleIndex *int if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 { subtitleIndex = &parsed } } subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles( ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "", ) streamURL := s.emby.StreamURL(cred, target.ID) if negotiatedURL != "" { streamURL = negotiatedURL } writeJSON(w, http.StatusOK, playbackResponse{ ItemID: target.ID, Title: title, URL: streamURL, ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), Subtitles: subtitles, MediaSourceID: mediaSourceID, PlaySessionID: playSessionID, PlayMethod: playMethod, }) } // Current clients already know the selected item's type, title and cached resume point. // Accepting those as hints removes one serial Emby request from every launch. Older // clients omit them and retain the authoritative lookup above. func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) { itemType := strings.TrimSpace(r.URL.Query().Get("type")) switch { case strings.EqualFold(itemType, "Movie"): itemType = "Movie" case strings.EqualFold(itemType, "Episode"): itemType = "Episode" case strings.EqualFold(itemType, "Series"): itemType = "Series" default: return emby.Summary{}, false } resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64) item := emby.Summary{ ID: itemID, Name: strings.TrimSpace(r.URL.Query().Get("title")), Type: itemType, } item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond return item, true } // firstPlayableEpisode prefers the server's next-up choice and falls back to episode one. func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) { nextUp, err := s.emby.NextUp(ctx, cred, url.Values{ "SeriesId": {seriesID}, "Limit": {"1"}, "Fields": {"RunTimeTicks"}, "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": {"RunTimeTicks"}, "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 } // 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 { s.writeUpstreamError(w, err, "could not load the item") return } var parsed struct { SeriesID string `json:"SeriesId"` } if err := json.Unmarshal(raw, &parsed); err != nil { writeError(w, http.StatusBadGateway, "unreadable item from emby") return } seriesID = parsed.SeriesID } if seriesID == "" { writeError(w, http.StatusNotFound, "this item is not part of a series") return } episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{ "AdjacentTo": {itemID}, "Fields": {"RunTimeTicks,Overview,SeriesName"}, "EnableUserData": {"true"}, "EnableImageTypes": {"Primary,Thumb"}, }) if err != nil { s.writeUpstreamError(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 } subtitles, mediaSourceID, playSessionID, _, playMethod := s.playbackSubtitles( ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", ) writeJSON(w, http.StatusOK, nextEpisodeResponse{ Item: raw, Title: title, URL: s.emby.StreamURL(cred, next.ID), ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), Subtitles: subtitles, MediaSourceID: mediaSourceID, PlaySessionID: playSessionID, PlayMethod: playMethod, }) } func (s *Server) playbackSubtitles( ctx context.Context, cred emby.Credentials, itemID string, startTicks int64, subtitleIndex *int, currentPlaySessionID string, ) ([]playableSubtitle, string, string, string, string) { info, err := s.emby.PlaybackInfo( ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, ) if err != nil { s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err) 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, }) } negotiatedURL := "" playMethod := "DirectPlay" if subtitleIndex != nil && source.TranscodingURL != "" { negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL) playMethod = "Transcode" } return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod } 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 } 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. s.log.Warn("playback report failed", "phase", phase, "error", err) } if phase == "stopped" { if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("cache invalidation failed", "error", err) } // Finishing something is the one event that genuinely changes viewing history, // so it is also the only thing that retires the recommendation rows. if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("recommendation invalidation failed", "error", err) } if s.forYou != nil { s.forYou.MarkDirty(r.Context(), sess) s.forYou.RefreshAsync(sess, true) } } w.WriteHeader(http.StatusNoContent) } func max64(v, floor int64) int64 { if v < floor { return floor } return v }