herald.go/server/search.go

614 lines
16 KiB
Go
Raw Normal View History

2021-04-19 21:25:34 +02:00
package server
import (
"context"
"encoding/hex"
2021-06-01 04:19:10 +02:00
"encoding/json"
2021-05-16 05:13:14 +02:00
"github.com/btcsuite/btcutil/base58"
2021-05-31 20:53:08 +02:00
"github.com/golang/protobuf/ptypes/wrappers"
pb "github.com/lbryio/hub/protobuf/go"
//"github.com/lbryio/hub/schema"
2021-06-04 07:56:50 +02:00
"github.com/lbryio/hub/util"
2021-04-19 21:25:34 +02:00
"github.com/olivere/elastic/v7"
"golang.org/x/text/cases"
2021-06-01 04:19:10 +02:00
"golang.org/x/text/language"
2021-05-31 20:53:08 +02:00
"log"
"reflect"
2021-06-06 06:57:37 +02:00
"sort"
2021-06-01 04:19:10 +02:00
"strings"
2021-04-19 21:25:34 +02:00
)
type record struct {
Txid string `json:"tx_id"`
Nout uint32 `json:"tx_nout"`
Height uint32 `json:"height"`
2021-06-04 07:56:50 +02:00
ClaimId string `json:"claim_id"`
2021-04-19 21:25:34 +02:00
}
2021-06-06 06:57:37 +02:00
type compareFunc func(r1, r2 *record, invert bool) int
type multiSorter struct {
records []record
compare []compareFunc
invert []bool
}
var compareFuncs = map[string]compareFunc {
"height": func(r1, r2 *record, invert bool) int {
var res = 0
if r1.Height < r2.Height {
res = -1
} else if r1.Height > r2.Height {
res = 1
}
if invert {
res = res * -1
}
return res
},
}
// Sort sorts the argument slice according to the less functions passed to OrderedBy.
func (ms *multiSorter) Sort(records []record) {
ms.records = records
sort.Sort(ms)
}
// OrderedBy returns a Sorter that sorts using the less functions, in order.
// Call its Sort method to sort the data.
func OrderedBy(compare ...compareFunc) *multiSorter {
return &multiSorter{
compare: compare,
}
}
// Len is part of sort.Interface.
func (ms *multiSorter) Len() int {
return len(ms.records)
}
// Swap is part of sort.Interface.
func (ms *multiSorter) Swap(i, j int) {
ms.records[i], ms.records[j] = ms.records[j], ms.records[i]
}
// Less is part of sort.Interface. It is implemented by looping along the
// less functions until it finds a comparison that discriminates between
// the two items (one is less than the other). Note that it can call the
// less functions twice per call. We could change the functions to return
// -1, 0, 1 and reduce the number of calls for greater efficiency: an
// exercise for the reader.
func (ms *multiSorter) Less(i, j int) bool {
p, q := &ms.records[i], &ms.records[j]
// Try all but the last comparison.
var k int
for k = 0; k < len(ms.compare)-1; k++ {
cmp := ms.compare[k]
res := cmp(p, q, ms.invert[k])
if res != 0 {
return res > 0
}
}
// All comparisons to here said "equal", so just return whatever
// the final comparison reports.
return ms.compare[k](p, q, ms.invert[k]) > 0
}
2021-05-18 12:02:55 +02:00
type orderField struct {
Field string
2021-06-06 06:57:37 +02:00
IsAsc bool
2021-05-18 12:02:55 +02:00
}
2021-06-04 07:56:50 +02:00
const (
errorResolution = iota
channelResolution = iota
streamResolution = iota
)
type urlResolution struct {
resolutionType int
value string
}
2021-05-16 05:13:14 +02:00
func StrArrToInterface(arr []string) []interface{} {
searchVals := make([]interface{}, len(arr))
for i := 0; i < len(arr); i++ {
searchVals[i] = arr[i]
}
return searchVals
}
2021-05-18 12:02:55 +02:00
func AddTermsField(arr []string, name string, q *elastic.BoolQuery) *elastic.BoolQuery {
2021-05-16 05:13:14 +02:00
if len(arr) > 0 {
searchVals := StrArrToInterface(arr)
return q.Must(elastic.NewTermsQuery(name, searchVals...))
}
return q
}
2021-06-01 04:19:10 +02:00
func AddIndividualTermFields(arr []string, name string, q *elastic.BoolQuery, invert bool) *elastic.BoolQuery {
if len(arr) > 0 {
for _, x := range arr {
if invert {
q = q.MustNot(elastic.NewTermQuery(name, x))
} else {
q = q.Must(elastic.NewTermQuery(name, x))
}
}
return q
}
return q
}
2021-05-18 12:02:55 +02:00
func AddRangeField(rq *pb.RangeField, name string, q *elastic.BoolQuery) *elastic.BoolQuery {
2021-05-16 05:13:14 +02:00
if rq == nil {
return q
}
if len(rq.Value) > 1 {
2021-05-18 12:02:55 +02:00
if rq.Op != pb.RangeField_EQ {
2021-05-16 05:13:14 +02:00
return q
}
2021-05-18 12:02:55 +02:00
return AddTermsField(rq.Value, name, q)
2021-05-16 05:13:14 +02:00
}
2021-05-18 12:02:55 +02:00
if rq.Op == pb.RangeField_EQ {
return q.Must(elastic.NewTermQuery(name, rq.Value[0]))
2021-05-18 12:02:55 +02:00
} else if rq.Op == pb.RangeField_LT {
2021-06-01 04:19:10 +02:00
return q.Must(elastic.NewRangeQuery(name).Lt(rq.Value[0]))
2021-05-18 12:02:55 +02:00
} else if rq.Op == pb.RangeField_LTE {
2021-06-01 04:19:10 +02:00
return q.Must(elastic.NewRangeQuery(name).Lte(rq.Value[0]))
2021-05-18 12:02:55 +02:00
} else if rq.Op == pb.RangeField_GT {
2021-06-01 04:19:10 +02:00
return q.Must(elastic.NewRangeQuery(name).Gt(rq.Value[0]))
2021-05-18 12:02:55 +02:00
} else { // pb.RangeField_GTE
2021-06-01 04:19:10 +02:00
return q.Must(elastic.NewRangeQuery(name).Gte(rq.Value[0]))
2021-05-16 05:13:14 +02:00
}
}
2021-05-18 12:02:55 +02:00
func AddInvertibleField(field *pb.InvertibleField, name string, q *elastic.BoolQuery) *elastic.BoolQuery {
if field == nil {
return q
}
searchVals := StrArrToInterface(field.Value)
if field.Invert {
2021-06-01 04:19:10 +02:00
q = q.MustNot(elastic.NewTermsQuery(name, searchVals...))
if name == "channel_id.keyword" {
q = q.MustNot(elastic.NewTermsQuery("_id", searchVals...))
}
return q
2021-05-18 12:02:55 +02:00
} else {
return q.Must(elastic.NewTermsQuery(name, searchVals...))
}
}
2021-06-01 04:19:10 +02:00
func (s *Server) normalizeTag(tag string) string {
c := cases.Lower(language.English)
res := s.MultiSpaceRe.ReplaceAll(
s.WeirdCharsRe.ReplaceAll(
[]byte(strings.TrimSpace(strings.Replace(c.String(tag), "'", "", -1))),
[]byte(" ")),
[]byte(" "))
return string(res)
}
func (s *Server) cleanTags(tags []string) []string {
cleanedTags := make([]string, len(tags))
for i, tag := range tags {
cleanedTags[i] = s.normalizeTag(tag)
}
return cleanedTags
}
2021-06-04 07:56:50 +02:00
func (s *Server) Search(ctx context.Context, in *pb.SearchRequest) (*pb.Outputs, error) {
var client *elastic.Client = nil
if s.EsClient == nil {
esUrl := s.Args.EsHost + ":" + s.Args.EsPort
tmpClient, err := elastic.NewClient(elastic.SetURL(esUrl), elastic.SetSniff(false))
2021-06-04 07:56:50 +02:00
if err != nil {
return nil, err
}
client = tmpClient
s.EsClient = client
} else {
client = s.EsClient
}
claimTypes := map[string]int {
"stream": 1,
"channel": 2,
"repost": 3,
"collection": 4,
}
2021-05-18 12:02:55 +02:00
streamTypes := map[string]int {
"video": 1,
"audio": 2,
"image": 3,
"document": 4,
"binary": 5,
"model": 6,
}
replacements := map[string]string {
"name": "normalized",
"txid": "tx_id",
"claim_hash": "_id",
}
textFields := map[string]bool {
"author": true,
"canonical_url": true,
"channel_id": true,
"claim_name": true,
"description": true,
"claim_id": true,
"media_type": true,
"normalized": true,
"public_key_bytes": true,
"public_key_hash": true,
"short_url": true,
"signature": true,
"signature_digest": true,
"stream_type": true,
"title": true,
"tx_id": true,
"fee_currency": true,
"reposted_claim_id": true,
"tags": true,
}
var from = 0
var size = 1000
var pageSize = 10
2021-05-18 12:02:55 +02:00
var orderBy []orderField
2021-06-06 06:57:37 +02:00
var ms *multiSorter
2021-05-18 12:02:55 +02:00
2021-04-19 21:25:34 +02:00
// Ping the Elasticsearch server to get e.g. the version number
2021-04-21 22:19:05 +02:00
//_, code, err := client.Ping("http://127.0.0.1:9200").Do(ctx)
//if err != nil {
// return nil, err
//}
//if code != 200 {
// return nil, errors.New("ping failed")
//}
2021-04-19 21:25:34 +02:00
2021-04-21 22:19:05 +02:00
// TODO: support all of this https://github.com/lbryio/lbry-sdk/blob/master/lbry/wallet/server/db/elasticsearch/search.py#L385
q := elastic.NewBoolQuery()
2021-05-31 20:53:08 +02:00
if in.IsControlling != nil {
q = q.Must(elastic.NewTermQuery("is_controlling", in.IsControlling.Value))
}
if in.AmountOrder != nil {
in.Limit.Value = 1
2021-05-18 12:02:55 +02:00
in.OrderBy = []string{"effective_amount"}
2021-05-31 20:53:08 +02:00
in.Offset = &wrappers.Int32Value{Value: in.AmountOrder.Value - 1}
}
2021-05-31 20:53:08 +02:00
if in.Limit != nil {
pageSize = int(in.Limit.Value)
log.Printf("page size: %d\n", pageSize)
2021-05-18 12:02:55 +02:00
}
2021-05-31 20:53:08 +02:00
if in.Offset != nil {
from = int(in.Offset.Value)
2021-05-18 12:02:55 +02:00
}
if len(in.Name) > 0 {
normalized := make([]string, len(in.Name))
for i := 0; i < len(in.Name); i++ {
2021-06-04 07:56:50 +02:00
normalized[i] = util.Normalize(in.Name[i])
}
in.Normalized = normalized
}
2021-05-18 12:02:55 +02:00
if len(in.OrderBy) > 0 {
for _, x := range in.OrderBy {
var toAppend string
2021-06-06 06:57:37 +02:00
var isAsc = false
2021-05-18 12:02:55 +02:00
if x[0] == '^' {
2021-06-06 06:57:37 +02:00
isAsc = true
2021-05-18 12:02:55 +02:00
x = x[1:]
}
if _, ok := replacements[x]; ok {
toAppend = replacements[x]
2021-05-31 20:53:08 +02:00
} else {
toAppend = x
2021-05-18 12:02:55 +02:00
}
2021-05-31 20:53:08 +02:00
if _, ok := textFields[toAppend]; ok {
toAppend = toAppend + ".keyword"
2021-05-18 12:02:55 +02:00
}
2021-06-06 06:57:37 +02:00
orderBy = append(orderBy, orderField{toAppend, isAsc})
}
ms = &multiSorter{
invert: make([]bool, len(orderBy)),
compare: make([]compareFunc, len(orderBy)),
}
for i, x := range orderBy {
ms.compare[i] = compareFuncs[x.Field]
ms.invert[i] = x.IsAsc
2021-05-18 12:02:55 +02:00
}
}
if len(in.ClaimType) > 0 {
searchVals := make([]interface{}, len(in.ClaimType))
for i := 0; i < len(in.ClaimType); i++ {
searchVals[i] = claimTypes[in.ClaimType[i]]
}
q = q.Must(elastic.NewTermsQuery("claim_type", searchVals...))
}
2021-05-18 12:02:55 +02:00
if len(in.StreamType) > 0 {
searchVals := make([]interface{}, len(in.StreamType))
for i := 0; i < len(in.StreamType); i++ {
searchVals[i] = streamTypes[in.StreamType[i]]
}
2021-06-01 04:19:10 +02:00
q = q.Must(elastic.NewTermsQuery("stream_type", searchVals...))
2021-05-18 12:02:55 +02:00
}
if len(in.XId) > 0 {
searchVals := make([]interface{}, len(in.XId))
for i := 0; i < len(in.XId); i++ {
2021-06-04 07:56:50 +02:00
util.ReverseBytes(in.XId[i])
searchVals[i] = hex.Dump(in.XId[i])
}
2021-05-16 05:13:14 +02:00
if len(in.XId) == 1 && len(in.XId[0]) < 20 {
q = q.Must(elastic.NewPrefixQuery("_id", string(in.XId[0])))
} else {
q = q.Must(elastic.NewTermsQuery("_id", searchVals...))
}
}
2021-05-16 05:13:14 +02:00
2021-05-18 12:02:55 +02:00
if in.ClaimId != nil {
searchVals := StrArrToInterface(in.ClaimId.Value)
if len(in.ClaimId.Value) == 1 && len(in.ClaimId.Value[0]) < 20 {
if in.ClaimId.Invert {
q = q.MustNot(elastic.NewPrefixQuery("claim_id.keyword", in.ClaimId.Value[0]))
} else {
q = q.Must(elastic.NewPrefixQuery("claim_id.keyword", in.ClaimId.Value[0]))
}
2021-05-16 05:13:14 +02:00
} else {
2021-05-18 12:02:55 +02:00
if in.ClaimId.Invert {
q = q.MustNot(elastic.NewTermsQuery("claim_id.keyword", searchVals...))
} else {
q = q.Must(elastic.NewTermsQuery("claim_id.keyword", searchVals...))
}
2021-05-16 05:13:14 +02:00
}
}
if in.PublicKeyId != "" {
2021-05-31 20:53:08 +02:00
value := hex.EncodeToString(base58.Decode(in.PublicKeyId)[1:21])
2021-05-16 05:13:14 +02:00
q = q.Must(elastic.NewTermQuery("public_key_hash.keyword", value))
}
2021-05-31 20:53:08 +02:00
if in.HasChannelSignature != nil && in.HasChannelSignature.Value {
q = q.Must(elastic.NewExistsQuery("signature_digest"))
if in.SignatureValid != nil {
q = q.Must(elastic.NewTermQuery("signature_valid", in.SignatureValid.Value))
2021-05-18 12:02:55 +02:00
}
2021-05-31 20:53:08 +02:00
} else if in.SignatureValid != nil {
2021-05-18 12:02:55 +02:00
q = q.MinimumNumberShouldMatch(1)
q = q.Should(elastic.NewBoolQuery().MustNot(elastic.NewExistsQuery("signature_digest")))
2021-05-31 20:53:08 +02:00
q = q.Should(elastic.NewTermQuery("signature_valid", in.SignatureValid.Value))
2021-05-18 12:02:55 +02:00
}
2021-05-31 20:53:08 +02:00
if in.HasSource != nil {
2021-05-18 12:02:55 +02:00
q = q.MinimumNumberShouldMatch(1)
2021-05-31 20:53:08 +02:00
isStreamOrRepost := elastic.NewTermsQuery("claim_type", claimTypes["stream"], claimTypes["repost"])
q = q.Should(elastic.NewBoolQuery().Must(isStreamOrRepost, elastic.NewMatchQuery("has_source", in.HasSource.Value)))
q = q.Should(elastic.NewBoolQuery().MustNot(isStreamOrRepost))
2021-05-18 12:02:55 +02:00
q = q.Should(elastic.NewBoolQuery().Must(elastic.NewTermQuery("reposted_claim_type", claimTypes["channel"])))
}
//var collapse *elastic.CollapseBuilder
//if in.LimitClaimsPerChannel != nil {
// println(in.LimitClaimsPerChannel.Value)
// innerHit := elastic.
// NewInnerHit().
// //From(0).
// Size(int(in.LimitClaimsPerChannel.Value)).
// Name("channel_id")
// for _, x := range orderBy {
// innerHit = innerHit.Sort(x.Field, x.IsAsc)
// }
// collapse = elastic.NewCollapseBuilder("channel_id.keyword").InnerHit(innerHit)
//}
2021-05-18 12:02:55 +02:00
2021-05-31 20:53:08 +02:00
if in.TxNout != nil {
q = q.Must(elastic.NewTermQuery("tx_nout", in.TxNout.Value))
}
2021-05-18 12:02:55 +02:00
q = AddTermsField(in.PublicKeyHash, "public_key_hash.keyword", q)
q = AddTermsField(in.Author, "author.keyword", q)
q = AddTermsField(in.Title, "title.keyword", q)
q = AddTermsField(in.CanonicalUrl, "canonical_url.keyword", q)
q = AddTermsField(in.ClaimName, "claim_name.keyword", q)
q = AddTermsField(in.Description, "description.keyword", q)
q = AddTermsField(in.MediaType, "media_type.keyword", q)
q = AddTermsField(in.Normalized, "normalized.keyword", q)
q = AddTermsField(in.PublicKeyBytes, "public_key_bytes.keyword", q)
q = AddTermsField(in.ShortUrl, "short_url.keyword", q)
q = AddTermsField(in.Signature, "signature.keyword", q)
q = AddTermsField(in.SignatureDigest, "signature_digest.keyword", q)
q = AddTermsField(in.TxId, "tx_id.keyword", q)
q = AddTermsField(in.FeeCurrency, "fee_currency.keyword", q)
q = AddTermsField(in.RepostedClaimId, "reposted_claim_id.keyword", q)
2021-06-01 04:19:10 +02:00
q = AddTermsField(s.cleanTags(in.AnyTags), "tags.keyword", q)
q = AddIndividualTermFields(s.cleanTags(in.AllTags), "tags.keyword", q, false)
q = AddIndividualTermFields(s.cleanTags(in.NotTags), "tags.keyword", q, true)
q = AddTermsField(in.AnyLanguages, "languages", q)
q = AddIndividualTermFields(in.AllLanguages, "languages", q, false)
2021-05-18 12:02:55 +02:00
q = AddInvertibleField(in.ChannelId, "channel_id.keyword", q)
2021-05-31 20:53:08 +02:00
q = AddInvertibleField(in.ChannelIds, "channel_id.keyword", q)
2021-06-01 04:19:10 +02:00
2021-05-18 12:02:55 +02:00
q = AddRangeField(in.TxPosition, "tx_position", q)
q = AddRangeField(in.Amount, "amount", q)
q = AddRangeField(in.Timestamp, "timestamp", q)
q = AddRangeField(in.CreationTimestamp, "creation_timestamp", q)
q = AddRangeField(in.Height, "height", q)
q = AddRangeField(in.CreationHeight, "creation_height", q)
q = AddRangeField(in.ActivationHeight, "activation_height", q)
q = AddRangeField(in.ExpirationHeight, "expiration_height", q)
q = AddRangeField(in.ReleaseTime, "release_time", q)
q = AddRangeField(in.Reposted, "reposted", q)
q = AddRangeField(in.FeeAmount, "fee_amount", q)
q = AddRangeField(in.Duration, "duration", q)
q = AddRangeField(in.CensorType, "censor_type", q)
q = AddRangeField(in.ChannelJoin, "channel_join", q)
q = AddRangeField(in.EffectiveAmount, "effective_amount", q)
q = AddRangeField(in.SupportAmount, "support_amount", q)
q = AddRangeField(in.TrendingGroup, "trending_group", q)
q = AddRangeField(in.TrendingMixed, "trending_mixed", q)
q = AddRangeField(in.TrendingLocal, "trending_local", q)
q = AddRangeField(in.TrendingGlobal, "trending_global", q)
2021-05-16 05:13:14 +02:00
2021-05-31 20:53:08 +02:00
if in.Text != "" {
textQuery := elastic.NewSimpleQueryStringQuery(in.Text).
FieldWithBoost("claim_name", 4).
FieldWithBoost("channel_name", 8).
FieldWithBoost("title", 1).
FieldWithBoost("description", 0.5).
FieldWithBoost("author", 1).
FieldWithBoost("tags", 0.5)
q = q.Must(textQuery)
}
2021-06-01 04:19:10 +02:00
//TODO make this only happen in dev environment
2021-05-31 20:53:08 +02:00
indices, err := client.IndexNames()
if err != nil {
log.Fatalln(err)
}
var numIndices = 0
if len(indices) > 0 {
numIndices = len(indices) - 1
}
searchIndices := make([]string, numIndices)
2021-05-31 20:53:08 +02:00
j := 0
for i := 0; i < len(indices); i++ {
if indices[i] == "claims" {
continue
}
searchIndices[j] = indices[i]
j = j + 1
}
fsc := elastic.NewFetchSourceContext(true).Exclude("description", "title")
2021-06-06 06:57:37 +02:00
log.Printf("from: %d, size: %d\n", from, size)
2021-05-18 12:02:55 +02:00
search := client.Search().
2021-05-31 20:53:08 +02:00
Index(searchIndices...).
FetchSourceContext(fsc).
2021-04-19 21:25:34 +02:00
Query(q). // specify the query
2021-05-18 12:02:55 +02:00
From(from).Size(size)
//if in.LimitClaimsPerChannel != nil {
// search = search.Collapse(collapse)
//}
2021-05-18 12:02:55 +02:00
for _, x := range orderBy {
2021-06-06 06:57:37 +02:00
log.Println(x.Field, x.IsAsc)
search = search.Sort(x.Field, x.IsAsc)
2021-05-18 12:02:55 +02:00
}
searchResult, err := search.Do(ctx) // execute
2021-04-19 21:25:34 +02:00
if err != nil {
return nil, err
}
2021-05-31 20:53:08 +02:00
log.Printf("%s: found %d results in %dms\n", in.Text, len(searchResult.Hits.Hits), searchResult.TookInMillis)
2021-04-19 21:25:34 +02:00
2021-06-06 06:57:37 +02:00
var txos []*pb.Output
var records []record
2021-04-19 21:25:34 +02:00
//if in.LimitClaimsPerChannel == nil {
if true {
txos = make([]*pb.Output, searchResult.TotalHits())
records = make([]record, 0, searchResult.TotalHits())
2021-06-06 06:57:37 +02:00
var r record
for _, item := range searchResult.Each(reflect.TypeOf(r)) {
2021-06-06 06:57:37 +02:00
if t, ok := item.(record); ok {
records = append(records, t)
//txos[i] = &pb.Output{
// TxHash: util.ToHash(t.Txid),
// Nout: t.Nout,
// Height: t.Height,
//}
2021-06-06 06:57:37 +02:00
}
}
} else {
records = make([]record, 0, len(searchResult.Hits.Hits) * int(in.LimitClaimsPerChannel.Value))
2021-06-06 06:57:37 +02:00
txos = make([]*pb.Output, 0, len(searchResult.Hits.Hits) * int(in.LimitClaimsPerChannel.Value))
var i = 0
for _, hit := range searchResult.Hits.Hits {
if innerHit, ok := hit.InnerHits["channel_id"]; ok {
for _, hitt := range innerHit.Hits.Hits {
if i >= size {
break
}
var t record
err := json.Unmarshal(hitt.Source, &t)
if err != nil {
return nil, err
}
records = append(records, t)
i++
}
}
}
ms.Sort(records)
log.Println(records)
for _, t := range records {
res := &pb.Output{
2021-06-04 07:56:50 +02:00
TxHash: util.ToHash(t.Txid),
2021-04-19 21:25:34 +02:00
Nout: t.Nout,
Height: t.Height,
2021-04-19 21:25:34 +02:00
}
2021-06-06 06:57:37 +02:00
txos = append(txos, res)
2021-04-19 21:25:34 +02:00
}
}
for i, t := range records {
txos[i] = &pb.Output{
TxHash: util.ToHash(t.Txid),
Nout: t.Nout,
Height: t.Height,
}
}
2021-04-19 21:25:34 +02:00
// or if you want more control
2021-06-06 06:57:37 +02:00
//for _, hit := range searchResult.Hits.Hits {
// // hit.Index contains the name of the index
//
// var t map[string]interface{} // or could be a Record
// err := json.Unmarshal(hit.Source, &t)
// if err != nil {
// return nil, err
// }
//
// b, err := json.MarshalIndent(t, "", " ")
// if err != nil {
// fmt.Println("error:", err)
// }
// fmt.Println(string(b))
// //for k := range t {
// // fmt.Println(k)
// //}
// //return nil, nil
//}
2021-04-19 21:25:34 +02:00
2021-06-06 06:57:37 +02:00
log.Printf("totalhits: %d\n", searchResult.TotalHits())
return &pb.Outputs{
2021-06-01 04:19:10 +02:00
Txos: txos,
Total: uint32(searchResult.TotalHits()),
Offset: uint32(int64(from) + searchResult.TotalHits()),
2021-04-19 21:25:34 +02:00
}, nil
}