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 4–5 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
|
|
|
|
|
|
}
|
2026-08-09 12:53:25 +12:00
|
|
|
|
s.handleBrowseItems(w, r, sess, genre)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleLibraryItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
|
|
|
|
s.handleBrowseItems(w, r, sess, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleBrowseItems(
|
|
|
|
|
|
w http.ResponseWriter,
|
|
|
|
|
|
r *http.Request,
|
|
|
|
|
|
sess store.Session,
|
|
|
|
|
|
genre string,
|
|
|
|
|
|
) {
|
|
|
|
|
|
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-09 12:53:25 +12:00
|
|
|
|
if !ok || (genre == "" && itemType == "Movie,Series") {
|
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 genre != "" {
|
|
|
|
|
|
filterKey = "genre:" + genre
|
|
|
|
|
|
}
|
|
|
|
|
|
key := cache.UserKey(sess.EmbyUserID, "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)
|
2026-08-09 12:53:25 +12:00
|
|
|
|
if genre != "" {
|
|
|
|
|
|
params.Set("Genres", genre)
|
|
|
|
|
|
}
|
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)
|
|
|
|
|
|
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.
|
2026-08-09 12:53:25 +12:00
|
|
|
|
if offset == 0 && genre != "" {
|
2026-08-09 08:25:50 +12:00
|
|
|
|
s.loggerFor(ctx).Info("genre browsed", "genre", genre, "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 genre != "" {
|
2026-08-09 08:25:50 +12:00
|
|
|
|
s.loggerFor(ctx).Debug("genre page", "genre", genre, "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: 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 08:56:42 +12:00
|
|
|
|
// genreItemType keeps the old mixed search as the default for Search, while the Movies
|
|
|
|
|
|
// and TV Series destinations can ask for a shelf that never crosses media types.
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|