Memby v0.1.53: Android TV client plus gateway

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>
This commit is contained in:
ponzischeme89
2026-07-27 08:16:20 +12:00
co-authored by Claude Opus 5
commit 2ce405c540
99 changed files with 14433 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"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"`
}
type playbackReport struct {
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
IsPaused bool `json:"isPaused"`
}
// 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)
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
}
}
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
URL: s.emby.StreamURL(cred, target.ID),
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
})
}
// 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
}
// 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,
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)
}
}
w.WriteHeader(http.StatusNoContent)
}
func max64(v, floor int64) int64 {
if v < floor {
return floor
}
return v
}