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 } 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() 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 genre != "" { filterKey = "genre:" + genre } key := cache.UserKey(sess.EmbyUserID, "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 genre != "" { params.Set("Genres", genre) } // 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 && genre != "" { s.loggerFor(ctx).Info("genre browsed", "genre", genre, "results", len(items), "total", total) } else if offset == 0 { s.loggerFor(ctx).Info("library browsed", "type", itemType, "results", len(items), "total", total) } else if genre != "" { s.loggerFor(ctx).Debug("genre page", "genre", genre, "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: 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) } // 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 }