Files
memby/server/internal/api/genres.go
T
2026-08-09 08:25:50 +12:00

154 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
ctx := r.Context()
genre := strings.TrimSpace(r.PathValue("genre"))
if genre == "" {
writeError(w, http.StatusBadRequest, "a genre is required")
return
}
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
offset := queryOffset(r, "offset")
key := cache.UserKey(sess.EmbyUserID, "genre:"+genre+":"+itoa(offset)+":"+itoa(limit))
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{
"Genres": {genre},
"IncludeItemTypes": {"Movie,Series"},
"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)
// 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)
s.decorateItemRatings(ctx, items)
total := genreTotal(result.TotalRecordCount, offset, len(items), limit)
// The first page is somebody opening a genre, which is a navigation event worth the
// log; the pages after it are one viewer scrolling and would bury it.
if offset == 0 {
s.loggerFor(ctx).Info("genre browsed", "genre", genre, "results", len(items), "total", total)
} else {
s.loggerFor(ctx).Debug("genre page", "genre", genre, "offset", offset, "results", len(items))
}
body, err := json.Marshal(genrePage{
Genre: genre,
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)
}
// 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
}