Files

231 lines
9.0 KiB
Go
Raw Permalink Normal View History

2026-08-09 08:25:50 +12:00
package api
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Browsing a genre is a *filter*, not a search.
//
// The search page's genre chips used to run their label through /v1/search, which is a
// text query: "Drama" then matched a film called Drama, anything with the word in its
// overview, and — because relevance is a score rather than a rule — a scattering of titles
// that are not in the genre at all, while missing most of the ones that are. So this asks
// Emby the question actually being asked, with the genre as a filter, and answers a page
// at a time.
//
// It goes to Emby with the viewer's own credentials rather than to the imported catalogue,
// for the reason handleSearch does: the household copy may hold titles a library
// permission or a parental control hides from this person, so it cannot be the authority
// on what they may see.
const (
// A screenful on a television grid is 45 columns of about 3 rows. This is several of
// those, so the scroll reaches the next page long before the viewer reaches the end of
// this one, and small enough that opening a genre is one quick request rather than a
// wait on a library's worth of Comedy.
genrePageSize = 48
genrePageMax = 100
)
// genrePage is the wire shape. The total is what lets the television stop asking: a page
// short of the limit also ends the scroll, but a genre whose last page happens to divide
// evenly would otherwise cost one more empty request to discover that.
type genrePage struct {
Genre string `json:"genre"`
Items []json.RawMessage `json:"items"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Total int `json:"total"`
}
func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
genre := strings.TrimSpace(r.PathValue("genre"))
if genre == "" {
writeError(w, http.StatusBadRequest, "a genre is required")
return
}
s.handleBrowseItems(w, r, sess, "Genres", "genre", genre)
2026-08-09 12:53:25 +12:00
}
func (s *Server) handleLibraryItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.handleBrowseItems(w, r, sess, "Genres", "genre", "")
}
// handleServiceItems answers the Genres page's Services shortcuts — Apple TV+, Netflix,
// Disney+ and the rest of the studio/network catalogue in ui/genre/GenreCategories.kt.
//
// It is the same shelf as a genre, filtered on a different Emby field: services are
// filtered on Studios, which is where a metadata agent (TMDb chief among them) records a
// title's production or distribution company — "Netflix", "Apple TV+", "Disney+" are
// ordinary studio names to Emby, so no new metadata or import step is needed. Like
// genres, the catalogue mapping a service to its Emby spellings is a client-side product
// list rather than server data, for the same reason genreCategories() is: the order is a
// design decision that must never jump around while home rows are arriving, and a service
// with no matching titles simply returns an empty page rather than needing to be hidden
// from a central registry.
func (s *Server) handleServiceItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
service := strings.TrimSpace(r.PathValue("service"))
if service == "" {
writeError(w, http.StatusBadRequest, "a service is required")
return
}
s.handleBrowseItems(w, r, sess, "Studios", "service", service)
2026-08-09 12:53:25 +12:00
}
func (s *Server) handleBrowseItems(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
// filterField is the Emby query parameter the filter value is written into — "Genres"
// for a genre shelf, "Studios" for a service shelf. filterLabel is only for the cache
// key and the log line, so the two shelves' entries can never collide or be confused
// for one another in a shared keyspace.
filterField string,
filterLabel string,
filterValue string,
2026-08-09 12:53:25 +12:00
) {
ctx := r.Context()
2026-08-09 08:25:50 +12:00
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
offset := queryOffset(r, "offset")
2026-08-09 08:56:42 +12:00
itemType, ok := genreItemType(r.URL.Query().Get("type"))
2026-08-17 13:13:10 +12:00
if !ok {
2026-08-09 08:56:42 +12:00
writeError(w, http.StatusBadRequest, "type must be Movie or Series")
return
}
2026-08-09 08:25:50 +12:00
2026-08-09 12:53:25 +12:00
filterKey := "all"
if filterValue != "" {
filterKey = filterLabel + ":" + filterValue
2026-08-09 12:53:25 +12:00
}
2026-08-20 15:06:00 +12:00
key := cache.UserKey(viewerKeyOf(ctx, sess), "browse:"+itemType+":"+filterKey+":"+itoa(offset)+":"+itoa(limit))
2026-08-09 08:25:50 +12:00
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
params := rowParams(url.Values{
2026-08-09 08:56:42 +12:00
"IncludeItemTypes": {itemType},
2026-08-09 08:25:50 +12:00
"Recursive": {"true"},
"StartIndex": {itoa(offset)},
"Limit": {itoa(limit)},
// Newest first, because a genre is browsed to find something to watch and the
// alphabet is not an answer to that. The second key is what makes paging safe:
// with only a date, two titles sharing one could swap places between requests and
// the scroll would repeat one card and never show the other.
"SortBy": {"PremiereDate,SortName"},
"SortOrder": {"Descending"},
}, fieldsRow)
if filterValue != "" {
params.Set(filterField, filterValue)
2026-08-09 12:53:25 +12:00
}
2026-08-09 08:25:50 +12:00
// rowParams turns this off for the home rows, which never page. Here it is the number
// the scroll stops on.
params.Set("EnableTotalRecordCount", "true")
// Episodes are deliberately not among the types. An episode inherits its series'
// genres, so including them would fill a page with twenty entries of one comedy and
// bury the nineteen other shows behind it.
result, err := s.emby.Items(ctx, credentials(sess), params)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not browse genre")
return
}
items := nonNil(result.Items)
2026-08-20 15:06:00 +12:00
s.decorateItems(ctx, items)
2026-08-09 08:25:50 +12:00
total := genreTotal(result.TotalRecordCount, offset, len(items), limit)
// The first page is somebody opening a genre or a service, which is a navigation event
// worth the log; the pages after it are one viewer scrolling and would bury it.
if offset == 0 && filterValue != "" {
s.loggerFor(ctx).Info(filterLabel+" browsed", filterLabel, filterValue, "results", len(items), "total", total)
2026-08-09 12:53:25 +12:00
} else if offset == 0 {
s.loggerFor(ctx).Info("library browsed", "type", itemType, "results", len(items), "total", total)
} else if filterValue != "" {
s.loggerFor(ctx).Debug(filterLabel+" page", filterLabel, filterValue, "offset", offset, "results", len(items))
2026-08-09 12:53:25 +12:00
} else {
s.loggerFor(ctx).Debug("library page", "type", itemType, "offset", offset, "results", len(items))
2026-08-09 08:25:50 +12:00
}
body, err := json.Marshal(genrePage{
Genre: filterValue,
2026-08-09 08:25:50 +12:00
Items: items,
Offset: offset,
Limit: limit,
Total: total,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build genre results")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
s.loggerFor(ctx).Warn("genre cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
2026-08-17 13:13:10 +12:00
// genreItemType keeps the mixed shelf as the default, which is what the Search chips and
// the Genres destination ask for, while the Movies and TV Series destinations name a type
// and get a shelf that never crosses media types.
//
// The unfiltered browse used to refuse the mixed type, on the reasoning that a whole
// library with no genre and no media type is not a shelf anybody asked for. The Genres
// destination is exactly that request — its "All genres" entry is the catalogue itself —
// and refusing it here only made the one entry at the top of that rail the one entry that
// could not answer.
2026-08-09 08:56:42 +12:00
func genreItemType(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return "Movie,Series", true
case "movie":
return "Movie", true
case "series":
return "Series", true
default:
return "", false
}
}
2026-08-09 08:25:50 +12:00
// genreTotal is what the television's scroll stops on, and it has to be right in the case
// where nobody counted.
//
// Emby answers TotalRecordCount when it is asked to, and that is the honest number. When
// it does not (an older build, or a library it will not count), the page itself is the only
// evidence: a *full* page means there may well be more, so the total is nudged one past
// what has been delivered and the scroll asks again; a short page is the end of the genre,
// so the total is exactly what has been delivered and the scroll stops. Getting that
// backwards either strands the viewer half way through a genre or leaves the grid asking
// for a page that will never come.
func genreTotal(reported, offset, count, limit int) int {
if reported > 0 {
return reported
}
total := offset + count
if count >= limit && limit > 0 {
total++
}
return total
}
// queryOffset is queryInt's other half: an offset of zero is a legal value rather than a
// missing one, which is exactly the case queryInt reads as "use the fallback".
func queryOffset(r *http.Request, key string) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return 0
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0
}
return v
}