Files

231 lines
9.0 KiB
Go
Raw Permalink 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) {
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)
}
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)
}
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,
) {
ctx := r.Context()
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
offset := queryOffset(r, "offset")
itemType, ok := genreItemType(r.URL.Query().Get("type"))
if !ok {
writeError(w, http.StatusBadRequest, "type must be Movie or Series")
return
}
filterKey := "all"
if filterValue != "" {
filterKey = filterLabel + ":" + filterValue
}
key := cache.UserKey(viewerKeyOf(ctx, sess), "browse:"+itemType+":"+filterKey+":"+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{
"IncludeItemTypes": {itemType},
"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)
}
// 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.decorateItems(ctx, items)
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)
} 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))
} else {
s.loggerFor(ctx).Debug("library page", "type", itemType, "offset", offset, "results", len(items))
}
body, err := json.Marshal(genrePage{
Genre: filterValue,
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)
}
// 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.
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
}
}
// 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
}