package api import ( "context" "encoding/json" "net/http" "strconv" "strings" "sync" "time" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/store" ) // External discovery is a separate route from search on purpose. // // /v1/search answers out of Emby and Redis and is what a keystroke asks. This asks Sonarr // and Radarr, which are somebody else's services on somebody else's network — a lookup // measured in seconds, rate-limited upstream, and worth nothing at all for the letters on // the way to a word. Splitting them is the whole of the promise that discovery can never // delay the library: the television makes the two requests independently and renders // whichever answers, so an *arr that is down or slow costs the second section and never // the first. const ( // Three characters, not the search floor of two. Two letters match a large part of any // catalogue and the answer is noise; the *arrs are also the one backend here that Memby // does not own, so the floor is set by what is worth asking them rather than by what // they would tolerate. minDiscoveryQueryRunes = 3 // How long one query's answer is reused for the whole household. A film's existence in // TMDb does not change minute to minute, and this is what makes leaving Search and // coming back, deleting and retyping a title, or a second television asking the same // thing free. It is deliberately well past the client's own debounce: the debounce // protects against typing, this protects against everything else. discoveryCacheTTL = 10 * time.Minute // A lookup that found nothing is kept for less. Nothing is the answer for a typo, which // is the query most likely to be corrected and asked again a second later — but it is // also the answer while an *arr is confused, and that recovers. discoveryEmptyTTL = 2 * time.Minute // Bounds the whole route. Longer than a library search, because that is the trade this // route exists to isolate, and short enough that a television is never left with a // section claiming it is still finding things for a minute. discoveryTimeout = 8 * time.Second ) // discoveryResponse is deliberately not just an item list: a television has to be able to // tell "there is nothing else to be had" from "we could not ask", because the first is an // answer and the second is worth a quiet line under the results. type discoveryResponse struct { Query string `json:"query"` Items []json.RawMessage `json:"items"` // Partial means at least one source failed. The items that did arrive are still good. Partial bool `json:"partial,omitempty"` // Skipped names why nothing was asked at all, for the log and for the client's own // diagnostics. An empty list with no reason means the *arrs simply knew nothing. Skipped string `json:"skipped,omitempty"` } // normaliseDiscoveryQuery is what makes the cache and the client's own deduplication agree // on when two searches are the same search. "Disclosure", "disclosure" and "disclosure " // are one question; "disclosure day" is another. func normaliseDiscoveryQuery(term string) string { return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(term))), " ") } // discoveryQueryEligible is the one rule, counted in runes rather than bytes for the reason // searchQueryRecordable is: a title in Japanese is rejected at a third of an English one's // length otherwise. func discoveryQueryEligible(term string) bool { return len([]rune(normaliseDiscoveryQuery(term))) >= minDiscoveryQueryRunes } func (s *Server) handleSearchDiscover(w http.ResponseWriter, r *http.Request, sess store.Session) { started := time.Now() term := normaliseDiscoveryQuery(r.URL.Query().Get("q")) limit := queryInt(r, "limit", 20, 60) log := s.loggerFor(r.Context()) if !discoveryQueryEligible(term) { s.writeDiscovery(w, discoveryResponse{Query: term, Skipped: "query_too_short"}) return } sonarrOn := s.sonarrEnabled(r.Context()) radarrOn := s.radarrEnabled(r.Context()) if !sonarrOn && !radarrOn { s.writeDiscovery(w, discoveryResponse{Query: term, Skipped: "no_sources"}) return } ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout) defer cancel() canRequest := s.requestAllowed(r, sess) type leg struct { source string items []json.RawMessage cached bool err error took time.Duration } results := make(chan leg, 2) var wg sync.WaitGroup // Sonarr and Radarr are independent, and one being unreachable must never cost the // other's answer — the same reason the two halves of a request lookup run apart. start := func(source string, load func(context.Context) ([]json.RawMessage, error)) { wg.Add(1) go func() { defer wg.Done() at := time.Now() items, cached, err := s.discoverCached(ctx, source, term, limit, load) results <- leg{source: source, items: items, cached: cached, err: err, took: time.Since(at)} }() } if sonarrOn { start("sonarr", func(ctx context.Context) ([]json.RawMessage, error) { return s.discoverSonarr(ctx, term, limit) }) } if radarrOn { start("radarr", func(ctx context.Context) ([]json.RawMessage, error) { return s.discoverRadarr(ctx, term, limit) }) } go func() { wg.Wait(); close(results) }() merged := make([]json.RawMessage, 0, limit) response := discoveryResponse{Query: term} fields := []any{"query", term, "limit", limit} found := 0 for result := range results { if result.err != nil { response.Partial = true log.Debug("discovery source failed", "query", term, "source", result.source, "error", result.err) } found += len(result.items) for _, item := range result.items { merged = mergeSearchRaw(merged, item, limit) } fields = append(fields, result.source, len(result.items), result.source+"_ms", result.took.Milliseconds(), result.source+"_cache", cacheWord(result.cached)) } response.Items = applyRequestable(merged, canRequest) // DEBUG for the reason the search line is: this is the record of what somebody was // looking for, and it is a per-viewer stream of titles rather than something the // ordinary log should carry. An operator tuning the debounce turns it on. log.Debug("search discovery", append(fields, "results", len(response.Items), "duplicates", found-len(response.Items), "partial", response.Partial, "ms", time.Since(started).Milliseconds())...) s.writeDiscovery(w, response) } func (s *Server) writeDiscovery(w http.ResponseWriter, response discoveryResponse) { if response.Items == nil { response.Items = []json.RawMessage{} } writeJSON(w, http.StatusOK, response) } func cacheWord(hit bool) string { if hit { return "hit" } return "miss" } // discoverCached is where the cooldown lives. // // The key is a MetadataKey rather than a UserKey, and that is load-bearing: what Radarr // knows about "disclosure day" is a fact about the world and the household's catalogue, // with nothing in it derived from whoever typed it — so one lookup answers for every // television in the house. Whether *this* viewer may press Request is applied afterwards, // on the way out, which is the same separation cache.MetadataKey demands everywhere else. // // cachedRead brings the other half for free: two televisions missing the same key in the // same second make one lookup between them rather than two. func (s *Server) discoverCached( ctx context.Context, source, term string, limit int, load func(context.Context) ([]json.RawMessage, error), ) ([]json.RawMessage, bool, error) { key := cache.MetadataKey("discover:v1:" + source + ":" + itoa(limit) + ":" + term) body, hit, err := s.cachedRead(ctx, key, discoveryCacheTTL, func(ctx context.Context) (json.RawMessage, error) { items, err := load(ctx) if err != nil { return nil, err } return json.Marshal(nonNil(items)) }) if err != nil { return nil, hit, err } var items []json.RawMessage if err := json.Unmarshal(body, &items); err != nil { return nil, hit, err } return items, hit, nil } // discoverSonarr and discoverRadarr differ from the streaming search's own legs in one // way: a title the household already has is dropped rather than listed. The library // section above it is already showing that title, playable, and one result appearing twice // under two headings is the single worst thing progressive search can do. func (s *Server) discoverSonarr(ctx context.Context, term string, limit int) ([]json.RawMessage, error) { series, err := s.sonarr.Lookup(ctx, term) if err != nil { return nil, err } ids := make([]int, 0, len(series)) for _, v := range series { if v.TVDBID > 0 { ids = append(ids, v.TVDBID) } } inLibrary, _ := s.store.LibraryContainsProviderIDs(ctx, "Tvdb", ids) items := make([]json.RawMessage, 0, limit) for _, v := range series { if v.TVDBID == 0 || inLibrary[v.TVDBID] || len(items) >= limit { continue } state := lookupStatusFor(RequestSubject{ Tracked: v.ID > 0, Released: seriesReleased(v.Status, v.NextAiring, time.Now()), }, false) items = append(items, searchExternalItem(v.Title, v.Year, v.Overview, "Series", "sonarr", state, strconv.Itoa(v.TVDBID), sonarrCoverURL(v.Images, "poster"), false)) } return items, nil } func (s *Server) discoverRadarr(ctx context.Context, term string, limit int) ([]json.RawMessage, error) { movies, err := s.radarr.Lookup(ctx, term) if err != nil { return nil, err } ids := make([]int, 0, len(movies)) for _, v := range movies { if v.TMDBID > 0 { ids = append(ids, v.TMDBID) } } inLibrary, _ := s.store.LibraryContainsProviderIDs(ctx, "Tmdb", ids) items := make([]json.RawMessage, 0, limit) for _, v := range movies { if v.TMDBID == 0 || inLibrary[v.TMDBID] || len(items) >= limit { continue } state := lookupStatusFor(RequestSubject{ Tracked: v.ID > 0, HasFile: v.HasFile, Released: movieReleased(v.Status), }, false) items = append(items, searchExternalItem(v.Title, v.Year, v.Overview, "Movie", "radarr", state, strconv.Itoa(v.TMDBID), radarrCoverURL(v.Images, "poster"), false)) } return items, nil } // applyRequestable is what keeps the cached half free of the viewer. Whether the button is // offered is this person's permission and nothing else's, so it is stamped on the way out // rather than baked into what the household shares. func applyRequestable(items []json.RawMessage, canRequest bool) []json.RawMessage { for i, raw := range items { var item map[string]any if json.Unmarshal(raw, &item) != nil { continue } state, _ := item["MembySearchState"].(string) item["MembyRequestable"] = canRequest && state == RequestStatusRequestable if encoded, err := json.Marshal(item); err == nil { items[i] = encoded } } return items }