Skip to content

Commit 3fef207

Browse files
authored
feat(search): add index-free (no_index) live search mode (#9565)
Building and maintaining a search index costs storage and periodic upkeep, and the index can lag behind the real files. For occasional searches that is overkill. Add a new search_index option "no_index" backed by a searcher that, instead of querying a prebuilt index, walks the filesystem under the requested parent on each search (bounded by max_index_depth, ignore_paths and a 1000-result cap) and matches object names against the keywords with the same case-insensitive AND semantics as the indexed backends. Index mutating methods are no-ops and AutoUpdate is off, so nothing is persisted. Existing permission/hidden-path filtering in the search handler still applies to the results. Closes #9468
1 parent e62ed70 commit 3fef207

5 files changed

Lines changed: 179 additions & 1 deletion

File tree

internal/bootstrap/data/setting.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func InitialSettings() []model.SettingItem {
175175

176176
// single settings
177177
{Key: conf.Token, Value: token, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},
178-
{Key: conf.SearchIndex, Value: "none", Type: conf.TypeSelect, Options: "database,database_non_full_text,bleve,meilisearch,none", Group: model.INDEX},
178+
{Key: conf.SearchIndex, Value: "none", Type: conf.TypeSelect, Options: "database,database_non_full_text,bleve,meilisearch,no_index,none", Group: model.INDEX},
179179
{Key: conf.AutoUpdateIndex, Value: "false", Type: conf.TypeBool, Group: model.INDEX},
180180
{Key: conf.IgnorePaths, Value: "", Type: conf.TypeText, Group: model.INDEX, Flag: model.PRIVATE, Help: `one path per line`},
181181
{Key: conf.MaxIndexDepth, Value: "20", Type: conf.TypeNumber, Group: model.INDEX, Flag: model.PRIVATE, Help: `max depth of index`},

internal/search/import.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ import (
55
_ "github.com/alist-org/alist/v3/internal/search/db"
66
_ "github.com/alist-org/alist/v3/internal/search/db_non_full_text"
77
_ "github.com/alist-org/alist/v3/internal/search/meilisearch"
8+
_ "github.com/alist-org/alist/v3/internal/search/noindex"
89
)

internal/search/noindex/init.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package noindex
2+
3+
import (
4+
"github.com/alist-org/alist/v3/internal/search/searcher"
5+
)
6+
7+
var config = searcher.Config{
8+
Name: "no_index",
9+
// no persisted index, so there is nothing to auto-update
10+
AutoUpdate: false,
11+
}
12+
13+
func init() {
14+
searcher.RegisterSearcher(config, func() (searcher.Searcher, error) {
15+
return &NoIndex{}, nil
16+
})
17+
}

internal/search/noindex/search.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package noindex
2+
3+
import (
4+
"context"
5+
"path"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
10+
"github.com/alist-org/alist/v3/internal/conf"
11+
"github.com/alist-org/alist/v3/internal/fs"
12+
"github.com/alist-org/alist/v3/internal/model"
13+
"github.com/alist-org/alist/v3/internal/search/searcher"
14+
"github.com/alist-org/alist/v3/internal/setting"
15+
"github.com/pkg/errors"
16+
)
17+
18+
// maxResults caps how many matches a single live search collects, to bound the
19+
// cost of walking large storages without an index.
20+
const maxResults = 1000
21+
22+
// errStop aborts the walk once enough matches have been collected.
23+
var errStop = errors.New("enough results collected")
24+
25+
// NoIndex is an index-free searcher: instead of querying a prebuilt index it
26+
// walks the filesystem under the requested parent on every search and matches
27+
// object names against the keywords. It trades query speed for not having to
28+
// build and maintain an index. See issue #9468.
29+
type NoIndex struct{}
30+
31+
func (NoIndex) Config() searcher.Config {
32+
return config
33+
}
34+
35+
func (NoIndex) Search(ctx context.Context, req model.SearchReq) ([]model.SearchNode, int64, error) {
36+
keywords := strings.Fields(req.Keywords)
37+
parent := req.Parent
38+
rootObj, err := fs.Get(ctx, parent, &fs.GetArgs{NoLog: true})
39+
if err != nil {
40+
return nil, 0, errors.WithMessagef(err, "failed get dir [%s]", parent)
41+
}
42+
maxDepth := setting.GetInt(conf.MaxIndexDepth, 20)
43+
ignorePaths := conf.SlicesMap[conf.IgnorePaths]
44+
45+
var nodes []model.SearchNode
46+
walkFn := func(reqPath string, info model.Obj) error {
47+
// the parent itself is not a search result
48+
if reqPath == parent {
49+
return nil
50+
}
51+
for _, ignore := range ignorePaths {
52+
if strings.HasPrefix(reqPath, ignore) {
53+
if info.IsDir() {
54+
return filepath.SkipDir
55+
}
56+
return nil
57+
}
58+
}
59+
// scope: 0 for all, 1 for dir, 2 for file
60+
if (req.Scope == 1 && !info.IsDir()) || (req.Scope == 2 && info.IsDir()) {
61+
return nil
62+
}
63+
if matchKeywords(info.GetName(), keywords) {
64+
nodes = append(nodes, model.SearchNode{
65+
Parent: path.Dir(reqPath),
66+
Name: info.GetName(),
67+
IsDir: info.IsDir(),
68+
Size: info.GetSize(),
69+
})
70+
if len(nodes) >= maxResults {
71+
return errStop
72+
}
73+
}
74+
return nil
75+
}
76+
if err := fs.WalkFS(ctx, maxDepth, parent, rootObj, walkFn); err != nil && !errors.Is(err, errStop) {
77+
return nil, 0, err
78+
}
79+
80+
// stable ordering so pagination is consistent across repeated calls
81+
sort.Slice(nodes, func(i, j int) bool {
82+
if nodes[i].Name != nodes[j].Name {
83+
return nodes[i].Name < nodes[j].Name
84+
}
85+
return nodes[i].Parent < nodes[j].Parent
86+
})
87+
88+
total := int64(len(nodes))
89+
start := (req.Page - 1) * req.PerPage
90+
if start >= len(nodes) {
91+
return []model.SearchNode{}, total, nil
92+
}
93+
end := start + req.PerPage
94+
if end > len(nodes) {
95+
end = len(nodes)
96+
}
97+
return nodes[start:end], total, nil
98+
}
99+
100+
// matchKeywords reports whether name contains every keyword (case-insensitive),
101+
// matching the AND semantics of the indexed searchers. Empty keywords match all.
102+
func matchKeywords(name string, keywords []string) bool {
103+
lower := strings.ToLower(name)
104+
for _, kw := range keywords {
105+
if !strings.Contains(lower, strings.ToLower(kw)) {
106+
return false
107+
}
108+
}
109+
return true
110+
}
111+
112+
func (NoIndex) Index(ctx context.Context, node model.SearchNode) error {
113+
return nil
114+
}
115+
116+
func (NoIndex) BatchIndex(ctx context.Context, nodes []model.SearchNode) error {
117+
return nil
118+
}
119+
120+
func (NoIndex) Get(ctx context.Context, parent string) ([]model.SearchNode, error) {
121+
return []model.SearchNode{}, nil
122+
}
123+
124+
func (NoIndex) Del(ctx context.Context, prefix string) error {
125+
return nil
126+
}
127+
128+
func (NoIndex) Release(ctx context.Context) error {
129+
return nil
130+
}
131+
132+
func (NoIndex) Clear(ctx context.Context) error {
133+
return nil
134+
}
135+
136+
var _ searcher.Searcher = (*NoIndex)(nil)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package noindex
2+
3+
import "testing"
4+
5+
func TestMatchKeywords(t *testing.T) {
6+
cases := []struct {
7+
name string
8+
keywords []string
9+
want bool
10+
}{
11+
{"Death.End.Cursed.2024.mkv", []string{"death", "cursed"}, true},
12+
{"Death.End.Cursed.2024.mkv", []string{"Death", "CURSED"}, true}, // case-insensitive
13+
{"Death.End.Cursed.2024.mkv", []string{"death", "missing"}, false},
14+
{"holiday photos", nil, true}, // no keywords matches everything
15+
{"holiday photos", []string{}, true},
16+
{"a.txt", []string{"a", "txt"}, true},
17+
{"a.txt", []string{"b"}, false},
18+
}
19+
for _, c := range cases {
20+
if got := matchKeywords(c.name, c.keywords); got != c.want {
21+
t.Fatalf("matchKeywords(%q, %v) = %v, want %v", c.name, c.keywords, got, c.want)
22+
}
23+
}
24+
}

0 commit comments

Comments
 (0)