Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
3.2 KiB
Go
105 lines
3.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
type flagRequest struct {
|
|
Value bool `json:"value"`
|
|
}
|
|
|
|
// handleItem serves full metadata for one item. The TV asks for this only after D-pad
|
|
// focus settles, so it is worth caching for longer than a home row.
|
|
func (s *Server) handleItem(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
|
|
}
|
|
key := cache.UserKey(sess.EmbyUserID, "item:"+itemID)
|
|
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
w.Header().Set("X-Memby-Cache", "hit")
|
|
writeRaw(w, http.StatusOK, raw)
|
|
return
|
|
}
|
|
|
|
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
|
|
if err != nil {
|
|
s.writeUpstreamError(w, err, "could not load the item")
|
|
return
|
|
}
|
|
if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil {
|
|
s.log.Warn("item cache write failed", "error", err)
|
|
}
|
|
w.Header().Set("X-Memby-Cache", "miss")
|
|
writeRaw(w, http.StatusOK, item)
|
|
}
|
|
|
|
// handleTrailer answers with the item's first local trailer, or 404 when it has none.
|
|
// The screensaver's Play action uses this before asking for a playback URL.
|
|
func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
itemID := r.PathValue("id")
|
|
if itemID == "" {
|
|
writeError(w, http.StatusBadRequest, "item id is required")
|
|
return
|
|
}
|
|
result, err := s.emby.LocalTrailers(r.Context(), credentials(sess), itemID)
|
|
if err != nil {
|
|
s.writeUpstreamError(w, err, "could not load trailers")
|
|
return
|
|
}
|
|
if len(result.Items) == 0 {
|
|
writeError(w, http.StatusNotFound, "no trailer available")
|
|
return
|
|
}
|
|
writeRaw(w, http.StatusOK, result.Items[0])
|
|
}
|
|
|
|
func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
|
|
return s.emby.SetFavorite(r.Context(), credentials(sess), itemID, value)
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
|
|
return s.emby.SetPlayed(r.Context(), credentials(sess), itemID, value)
|
|
})
|
|
}
|
|
|
|
// setFlag applies a user-data mutation and drops this user's cached views, so the next
|
|
// home request reflects it rather than serving the row it just contradicted.
|
|
func (s *Server) setFlag(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
sess store.Session,
|
|
apply func(itemID string, value bool) (json.RawMessage, error),
|
|
) {
|
|
itemID := r.PathValue("id")
|
|
if itemID == "" {
|
|
writeError(w, http.StatusBadRequest, "item id is required")
|
|
return
|
|
}
|
|
var req flagRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
return
|
|
}
|
|
|
|
userData, err := apply(itemID, req.Value)
|
|
if err != nil {
|
|
s.writeUpstreamError(w, err, "could not update the item")
|
|
return
|
|
}
|
|
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
|
|
s.log.Warn("cache invalidation failed", "error", err)
|
|
}
|
|
writeRaw(w, http.StatusOK, userData)
|
|
}
|