From 0cccceccd9b1628c09b96ae541555da2cc9c20fc Mon Sep 17 00:00:00 2001 From: Worph Date: Sun, 13 Sep 2026 15:57:30 +0200 Subject: [PATCH] align with metadata standard --- internal/storage/field_index_test.go | 8 +++---- internal/storage/search_dirty.go | 31 +++++++++++++++++++++++++++ internal/storage/search_dirty_test.go | 19 ++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/internal/storage/field_index_test.go b/internal/storage/field_index_test.go index 1e85ba0..d9d3568 100644 --- a/internal/storage/field_index_test.go +++ b/internal/storage/field_index_test.go @@ -59,7 +59,7 @@ func TestFieldIndex_SetMetadataFlatIndexesFields(t *testing.T) { "filePath": "/files/watch/a.mkv", "sizeByte": "123", "cids/bafkreiaaa": "true", - "titles/jpn": "ナルト", + "titles/jpn/ナルト": "true", "categories/TV/UHD": "true", "info/files/0/path": "a.mkv", "provenance": `{"source":"gateway"}`, @@ -380,7 +380,7 @@ func TestFieldIndex_WarmBuildsEveryMissingIndex(t *testing.T) { // written through the normal path (already indexed). legacy := map[string]map[string]string{ "old1": {"filePath": "/a.mkv", "cids/bafkreiaaa": "true"}, - "old2": {"filePath": "/b.mkv", "titles/jpn": "ナルト", "deep/a/b": "x"}, + "old2": {"filePath": "/b.mkv", "titles/jpn/ナルト": "true", "deep/a/b": "x"}, } for root, fields := range legacy { for f, v := range fields { @@ -407,7 +407,7 @@ func TestFieldIndex_WarmBuildsEveryMissingIndex(t *testing.T) { if got := fieldNames(t, c, "old1"); !reflect.DeepEqual(got, []string{"cids/bafkreiaaa", "filePath"}) { t.Errorf("old1 index = %v", got) } - if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn"}) { + if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn/ナルト"}) { t.Errorf("old2 index = %v", got) } if got := fieldNames(t, c, "fresh"); !reflect.DeepEqual(got, []string{"title"}) { @@ -425,7 +425,7 @@ func TestFieldIndex_WarmBuildsEveryMissingIndex(t *testing.T) { if _, err := c.WarmFieldIndexes(); err != nil { t.Fatalf("second WarmFieldIndexes: %v", err) } - if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn"}) { + if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn/ナルト"}) { t.Errorf("old2 index after second warm = %v", got) } }) diff --git a/internal/storage/search_dirty.go b/internal/storage/search_dirty.go index 2f83ab8..4ebb55a 100644 --- a/internal/storage/search_dirty.go +++ b/internal/storage/search_dirty.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "sort" "strings" "time" ) @@ -202,6 +203,11 @@ func (c *Client) GetSearchIndexTuplesFor(hashIDs []string) (tuples []SearchTuple // blob. Shared by the full and incremental index reads so the two can never // disagree about what is searchable — a drift there would make a record's // matchability depend on which path happened to index it. +// +// Beyond the flat fields it folds in every `titles//` member's +// name (METADATA_KEYS.md §3): the name lives in the key and the value is the +// sentinel "true", so a search for an alternative title (`3 percent`) finds the +// record whose `title` is `3%`. func buildHaystack(fields map[string]string) string { var sb strings.Builder for _, f := range searchIndexFields { @@ -210,5 +216,30 @@ func buildHaystack(fields map[string]string) string { sb.WriteByte('\n') } } + for _, name := range titleMemberNames(fields) { + sb.WriteString(strings.ToLower(name)) + sb.WriteByte('\n') + } return sb.String() } + +// titleMemberNames returns the names of a record's `titles//` +// key-set members, with the `∕` (U+2215) a writer substitutes for `/` mapped +// back. Sorted: the fields map has no order, and the full and incremental +// builds must produce byte-identical haystacks. +func titleMemberNames(fields map[string]string) []string { + var names []string + for k, v := range fields { + rest, ok := strings.CutPrefix(k, "titles/") + if !ok || v == "" || v == "false" { + continue + } + _, name, ok := strings.Cut(rest, "/") + if !ok || name == "" { + continue + } + names = append(names, strings.ReplaceAll(name, "\u2215", "/")) + } + sort.Strings(names) + return names +} diff --git a/internal/storage/search_dirty_test.go b/internal/storage/search_dirty_test.go index a0c0784..20a3fa5 100644 --- a/internal/storage/search_dirty_test.go +++ b/internal/storage/search_dirty_test.go @@ -174,6 +174,8 @@ func TestDirty_IncrementalTuplesMatchTheFullRebuild(t *testing.T) { "title": "Parity Release", "fileName": "parity.s01e01.mkv", "fileType": "video", + "titles/eng/3 Percent": "true", + "titles/fra/3 Pourcent": "true", "categories/XXX": "true", "categories/newznab/5070": "true", }); err != nil { @@ -209,3 +211,20 @@ func TestDirty_IncrementalTuplesMatchTheFullRebuild(t *testing.T) { } }) } + +// A `titles//` member contributes its NAME (the key), never its +// "true" value, and the `∕` a writer substituted for `/` reads back as `/`. +func TestBuildHaystack_FoldsTitleMemberNames(t *testing.T) { + got := buildHaystack(map[string]string{ + "title": "3%", + "titles/fra/3 Pourcent": "true", + "titles/eng/3 Percent": "true", + "titles/und/Fate\u2215Zero": "true", + "titles/eng": "Retired Scalar", + "titles/eng/": "true", + }) + want := "3%\n3 percent\n3 pourcent\nfate/zero\n" + if got != want { + t.Errorf("haystack = %q, want %q", got, want) + } +}