From cb8b6375ccb42ac1a08229ee4ea369df740f8c34 Mon Sep 17 00:00:00 2001 From: okatu-loli Date: Tue, 23 Jun 2026 00:56:15 +0800 Subject: [PATCH] feat(search): add index-free (no_index) live search mode 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 --- internal/bootstrap/data/setting.go | 2 +- internal/search/import.go | 1 + internal/search/noindex/init.go | 17 ++++ internal/search/noindex/search.go | 136 +++++++++++++++++++++++++ internal/search/noindex/search_test.go | 24 +++++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 internal/search/noindex/init.go create mode 100644 internal/search/noindex/search.go create mode 100644 internal/search/noindex/search_test.go diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 71486335240..e646c4d80cf 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -175,7 +175,7 @@ func InitialSettings() []model.SettingItem { // single settings {Key: conf.Token, Value: token, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE}, - {Key: conf.SearchIndex, Value: "none", Type: conf.TypeSelect, Options: "database,database_non_full_text,bleve,meilisearch,none", Group: model.INDEX}, + {Key: conf.SearchIndex, Value: "none", Type: conf.TypeSelect, Options: "database,database_non_full_text,bleve,meilisearch,no_index,none", Group: model.INDEX}, {Key: conf.AutoUpdateIndex, Value: "false", Type: conf.TypeBool, Group: model.INDEX}, {Key: conf.IgnorePaths, Value: "", Type: conf.TypeText, Group: model.INDEX, Flag: model.PRIVATE, Help: `one path per line`}, {Key: conf.MaxIndexDepth, Value: "20", Type: conf.TypeNumber, Group: model.INDEX, Flag: model.PRIVATE, Help: `max depth of index`}, diff --git a/internal/search/import.go b/internal/search/import.go index a34c36f9a34..790e6307687 100644 --- a/internal/search/import.go +++ b/internal/search/import.go @@ -5,4 +5,5 @@ import ( _ "github.com/alist-org/alist/v3/internal/search/db" _ "github.com/alist-org/alist/v3/internal/search/db_non_full_text" _ "github.com/alist-org/alist/v3/internal/search/meilisearch" + _ "github.com/alist-org/alist/v3/internal/search/noindex" ) diff --git a/internal/search/noindex/init.go b/internal/search/noindex/init.go new file mode 100644 index 00000000000..a1bf1f4afbd --- /dev/null +++ b/internal/search/noindex/init.go @@ -0,0 +1,17 @@ +package noindex + +import ( + "github.com/alist-org/alist/v3/internal/search/searcher" +) + +var config = searcher.Config{ + Name: "no_index", + // no persisted index, so there is nothing to auto-update + AutoUpdate: false, +} + +func init() { + searcher.RegisterSearcher(config, func() (searcher.Searcher, error) { + return &NoIndex{}, nil + }) +} diff --git a/internal/search/noindex/search.go b/internal/search/noindex/search.go new file mode 100644 index 00000000000..759e004ff62 --- /dev/null +++ b/internal/search/noindex/search.go @@ -0,0 +1,136 @@ +package noindex + +import ( + "context" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/alist-org/alist/v3/internal/conf" + "github.com/alist-org/alist/v3/internal/fs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/search/searcher" + "github.com/alist-org/alist/v3/internal/setting" + "github.com/pkg/errors" +) + +// maxResults caps how many matches a single live search collects, to bound the +// cost of walking large storages without an index. +const maxResults = 1000 + +// errStop aborts the walk once enough matches have been collected. +var errStop = errors.New("enough results collected") + +// NoIndex is an index-free searcher: instead of querying a prebuilt index it +// walks the filesystem under the requested parent on every search and matches +// object names against the keywords. It trades query speed for not having to +// build and maintain an index. See issue #9468. +type NoIndex struct{} + +func (NoIndex) Config() searcher.Config { + return config +} + +func (NoIndex) Search(ctx context.Context, req model.SearchReq) ([]model.SearchNode, int64, error) { + keywords := strings.Fields(req.Keywords) + parent := req.Parent + rootObj, err := fs.Get(ctx, parent, &fs.GetArgs{NoLog: true}) + if err != nil { + return nil, 0, errors.WithMessagef(err, "failed get dir [%s]", parent) + } + maxDepth := setting.GetInt(conf.MaxIndexDepth, 20) + ignorePaths := conf.SlicesMap[conf.IgnorePaths] + + var nodes []model.SearchNode + walkFn := func(reqPath string, info model.Obj) error { + // the parent itself is not a search result + if reqPath == parent { + return nil + } + for _, ignore := range ignorePaths { + if strings.HasPrefix(reqPath, ignore) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + // scope: 0 for all, 1 for dir, 2 for file + if (req.Scope == 1 && !info.IsDir()) || (req.Scope == 2 && info.IsDir()) { + return nil + } + if matchKeywords(info.GetName(), keywords) { + nodes = append(nodes, model.SearchNode{ + Parent: path.Dir(reqPath), + Name: info.GetName(), + IsDir: info.IsDir(), + Size: info.GetSize(), + }) + if len(nodes) >= maxResults { + return errStop + } + } + return nil + } + if err := fs.WalkFS(ctx, maxDepth, parent, rootObj, walkFn); err != nil && !errors.Is(err, errStop) { + return nil, 0, err + } + + // stable ordering so pagination is consistent across repeated calls + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].Name != nodes[j].Name { + return nodes[i].Name < nodes[j].Name + } + return nodes[i].Parent < nodes[j].Parent + }) + + total := int64(len(nodes)) + start := (req.Page - 1) * req.PerPage + if start >= len(nodes) { + return []model.SearchNode{}, total, nil + } + end := start + req.PerPage + if end > len(nodes) { + end = len(nodes) + } + return nodes[start:end], total, nil +} + +// matchKeywords reports whether name contains every keyword (case-insensitive), +// matching the AND semantics of the indexed searchers. Empty keywords match all. +func matchKeywords(name string, keywords []string) bool { + lower := strings.ToLower(name) + for _, kw := range keywords { + if !strings.Contains(lower, strings.ToLower(kw)) { + return false + } + } + return true +} + +func (NoIndex) Index(ctx context.Context, node model.SearchNode) error { + return nil +} + +func (NoIndex) BatchIndex(ctx context.Context, nodes []model.SearchNode) error { + return nil +} + +func (NoIndex) Get(ctx context.Context, parent string) ([]model.SearchNode, error) { + return []model.SearchNode{}, nil +} + +func (NoIndex) Del(ctx context.Context, prefix string) error { + return nil +} + +func (NoIndex) Release(ctx context.Context) error { + return nil +} + +func (NoIndex) Clear(ctx context.Context) error { + return nil +} + +var _ searcher.Searcher = (*NoIndex)(nil) diff --git a/internal/search/noindex/search_test.go b/internal/search/noindex/search_test.go new file mode 100644 index 00000000000..ee28c3e1af1 --- /dev/null +++ b/internal/search/noindex/search_test.go @@ -0,0 +1,24 @@ +package noindex + +import "testing" + +func TestMatchKeywords(t *testing.T) { + cases := []struct { + name string + keywords []string + want bool + }{ + {"Death.End.Cursed.2024.mkv", []string{"death", "cursed"}, true}, + {"Death.End.Cursed.2024.mkv", []string{"Death", "CURSED"}, true}, // case-insensitive + {"Death.End.Cursed.2024.mkv", []string{"death", "missing"}, false}, + {"holiday photos", nil, true}, // no keywords matches everything + {"holiday photos", []string{}, true}, + {"a.txt", []string{"a", "txt"}, true}, + {"a.txt", []string{"b"}, false}, + } + for _, c := range cases { + if got := matchKeywords(c.name, c.keywords); got != c.want { + t.Fatalf("matchKeywords(%q, %v) = %v, want %v", c.name, c.keywords, got, c.want) + } + } +}