Release v0.2.34

This commit is contained in:
ponzischeme89
2026-08-09 08:25:50 +12:00
parent b1128bcce2
commit fdd9e6cab2
116 changed files with 11418 additions and 879 deletions
+50 -1
View File
@@ -522,6 +522,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
limit := queryInt(r, "limit", 40, 100)
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
// Every search the tab performs is recorded here, before the cache is consulted, so a
// query answered from Redis counts the same as one that reached Emby. The client also
// posts to /v1/search/history and an older APK is the only thing that records at all —
// recordSearchQuery's dedupe window is what stops the two writing the same query twice.
s.recordSearchQuery(ctx, sess, term)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
@@ -560,6 +566,49 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
writeRaw(w, http.StatusOK, body)
}
const (
// minSearchQueryRunes matches the client's own floor: one letter matches half a
// library, so the search tab does not ask below two and neither route records below it.
minSearchQueryRunes = 2
// maxSearchQueryRunes bounds what is written to search_history. The query arrives in a
// URL on one of the two routes, so the table's row size must not be the client's to
// choose. Runes rather than bytes, or a title in Japanese is rejected at a third of the
// length of one in English.
maxSearchQueryRunes = 200
)
// searchQueryRecordable is the one rule both routes apply, so a query the search handler
// records is exactly one the history endpoint would have accepted.
func searchQueryRecordable(term string) bool {
n := len([]rune(strings.TrimSpace(term)))
return n >= minSearchQueryRunes && n <= maxSearchQueryRunes
}
// recordSearchQuery writes a query the search tab performed, and never makes the viewer
// wait for it.
//
// Detached from the request context deliberately: instant search cancels the in-flight
// request on every keystroke (the client's collectLatest), so a write hung off r.Context()
// would be abandoned for precisely the searches somebody typed fastest — and the record is
// worth having whether or not they waited for the results.
func (s *Server) recordSearchQuery(ctx context.Context, sess store.Session, term string) {
if s.store == nil || !searchQueryRecordable(term) {
return
}
term = strings.TrimSpace(term)
log := s.loggerFor(ctx)
detached := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(detached, 5*time.Second)
defer cancel()
if err := s.store.RecordSearch(ctx, sess.EmbyUserID, term); err != nil {
// Telemetry, not the answer: a search whose record failed still returns
// results, and this is DEBUG for the same reason the search line itself is.
log.Debug("search not recorded", "query", term, "error", err)
}
}()
}
type searchHistoryRequest struct {
Query string `json:"query"`
}
@@ -602,7 +651,7 @@ func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, ses
return
}
query := strings.TrimSpace(req.Query)
if len([]rune(query)) < 2 || len([]rune(query)) > 200 {
if !searchQueryRecordable(query) {
writeError(w, http.StatusBadRequest, "search query length is invalid")
return
}