Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cccceccd9 | ||
|
|
40b553016c | ||
|
|
4894810fd4 |
@@ -209,6 +209,7 @@ the `/file/{cid}` reverse-lookup path.)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/metadata/hash-ids
|
GET /api/metadata/hash-ids
|
||||||
|
GET /api/files/tuples
|
||||||
GET /api/metadata/list
|
GET /api/metadata/list
|
||||||
POST /api/metadata/search
|
POST /api/metadata/search
|
||||||
POST /api/metadata/batch
|
POST /api/metadata/batch
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ CID-addressed (public, auth-bypassed at the perimeter)
|
|||||||
|
|
||||||
Editor / KV / schema / snapshot
|
Editor / KV / schema / snapshot
|
||||||
GET /api/metadata/hash-ids
|
GET /api/metadata/hash-ids
|
||||||
|
GET /api/files/tuples
|
||||||
GET /api/metadata/list
|
GET /api/metadata/list
|
||||||
POST /api/metadata/search
|
POST /api/metadata/search
|
||||||
POST /api/metadata/batch
|
POST /api/metadata/batch
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// fileTupleJSON is one file-backed root on the wire.
|
||||||
|
type fileTupleJSON struct {
|
||||||
|
HashID string `json:"hashId"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
SizeByte int64 `json:"sizeByte"`
|
||||||
|
MtimeNano int64 `json:"mtimeNano"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileTuplesResponse is the body of GET /api/files/tuples.
|
||||||
|
//
|
||||||
|
// Files is a pointer so the summary form can omit it while the full form
|
||||||
|
// always carries it — an empty library is `"files": []`, never an absent key,
|
||||||
|
// which a client could mistake for a meta-core that lacks the rows.
|
||||||
|
type FileTuplesResponse struct {
|
||||||
|
Files *[]fileTupleJSON `json:"files,omitempty"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetFileTuples handles GET /api/files/tuples.
|
||||||
|
//
|
||||||
|
// Lists the FILE-BACKED roots — records carrying filePath, sizeByte and
|
||||||
|
// mtimeNano, i.e. what the watcher or /api/files/register minted — with their
|
||||||
|
// sizes, plus the count and total. Gateway / cid-rooted records carry no file
|
||||||
|
// and are not included; neither is a locator record that only merged a cache
|
||||||
|
// file's metadata (it has no mtimeNano).
|
||||||
|
//
|
||||||
|
// It exists so a client never walks every hashId to answer "how many files,
|
||||||
|
// how big": GetTuplesForAllFiles reads the three fields for every root in
|
||||||
|
// chunked MGETs server-side (~250 round-trips for 82k roots, no SCAN), where
|
||||||
|
// the client-side equivalent was one HTTP request per record.
|
||||||
|
//
|
||||||
|
// ?summary=1 omits the rows and returns only {count, totalSize}.
|
||||||
|
func (s *Server) handleGetFileTuples(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tuples, err := s.storage.GetTuplesForAllFiles()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := r.URL.Query().Get("summary")
|
||||||
|
withRows := summary == "" || summary == "0" || summary == "false"
|
||||||
|
|
||||||
|
resp := FileTuplesResponse{Count: len(tuples)}
|
||||||
|
var rows []fileTupleJSON
|
||||||
|
if withRows {
|
||||||
|
rows = make([]fileTupleJSON, 0, len(tuples))
|
||||||
|
}
|
||||||
|
for _, t := range tuples {
|
||||||
|
resp.TotalSize += t.Size
|
||||||
|
if withRows {
|
||||||
|
rows = append(rows, fileTupleJSON{
|
||||||
|
HashID: t.HashID,
|
||||||
|
FilePath: t.FilePath,
|
||||||
|
SizeByte: t.Size,
|
||||||
|
MtimeNano: t.MtimeNano,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if withRows {
|
||||||
|
resp.Files = &rows
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newFileTuplesTestServer(t *testing.T) (*Server, *storage.Client) {
|
||||||
|
t.Helper()
|
||||||
|
mr := miniredis.RunT(t)
|
||||||
|
c := storage.NewClient("")
|
||||||
|
if err := c.Connect("redis://" + mr.Addr()); err != nil {
|
||||||
|
t.Fatalf("connect to miniredis: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = c.Close() })
|
||||||
|
return &Server{storage: c}, c
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFileTuples(t *testing.T, s *Server, query string) (int, FileTuplesResponse) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/files/tuples"+query, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleGetFileTuples(rr, req)
|
||||||
|
var resp FileTuplesResponse
|
||||||
|
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||||
|
return rr.Code, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMint(t *testing.T, c *storage.Client, path string, size, mtime int64) string {
|
||||||
|
t.Helper()
|
||||||
|
id, err := c.Mint(path, size, mtime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only records that are actually files count: a gateway record and a record
|
||||||
|
// that merely carries a path + size (no mtimeNano) are both left out.
|
||||||
|
func TestFileTuples_OnlyFileBackedRoots(t *testing.T) {
|
||||||
|
s, c := newFileTuplesTestServer(t)
|
||||||
|
a := mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
|
||||||
|
b := mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
|
||||||
|
if err := c.SetMetadataFlat("bagcsgatewayrecord", map[string]string{"title": "Some Release"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.SetMetadataFlat("01partialroot", map[string]string{"filePath": "/files/cache/x.mkv", "sizeByte": "999"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, resp := getFileTuples(t, s, "")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d", code)
|
||||||
|
}
|
||||||
|
if resp.Count != 2 || resp.TotalSize != 3500 {
|
||||||
|
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
|
||||||
|
}
|
||||||
|
if resp.Files == nil || len(*resp.Files) != 2 {
|
||||||
|
t.Fatalf("want 2 rows, got %+v", resp.Files)
|
||||||
|
}
|
||||||
|
got := []string{(*resp.Files)[0].HashID, (*resp.Files)[1].HashID}
|
||||||
|
want := []string{a, b}
|
||||||
|
sort.Strings(got)
|
||||||
|
sort.Strings(want)
|
||||||
|
if got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("want roots %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileTuples_SummaryOmitsRows(t *testing.T) {
|
||||||
|
s, c := newFileTuplesTestServer(t)
|
||||||
|
mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
|
||||||
|
mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
|
||||||
|
|
||||||
|
code, resp := getFileTuples(t, s, "?summary=1")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d", code)
|
||||||
|
}
|
||||||
|
if resp.Files != nil {
|
||||||
|
t.Fatalf("summary must omit rows, got %d", len(*resp.Files))
|
||||||
|
}
|
||||||
|
if resp.Count != 2 || resp.TotalSize != 3500 {
|
||||||
|
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty library still carries "files": [] — clients treat an absent key as
|
||||||
|
// "this meta-core cannot list files", so it must not disappear when empty.
|
||||||
|
func TestFileTuples_EmptyLibraryKeepsFilesKey(t *testing.T) {
|
||||||
|
s, _ := newFileTuplesTestServer(t)
|
||||||
|
|
||||||
|
code, resp := getFileTuples(t, s, "")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d", code)
|
||||||
|
}
|
||||||
|
if resp.Files == nil || len(*resp.Files) != 0 || resp.Count != 0 {
|
||||||
|
t.Fatalf("want an empty files array and count 0, got %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileTuples_StorageNotConnected(t *testing.T) {
|
||||||
|
s := &Server{storage: storage.NewClient("")}
|
||||||
|
|
||||||
|
code, _ := getFileTuples(t, s, "")
|
||||||
|
if code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("want 503, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,6 +247,10 @@ func (s *Server) setupRoutes() {
|
|||||||
// mint + alias + file:events path for a single file. Registered
|
// mint + alias + file:events path for a single file. Registered
|
||||||
// unconditionally: it exists for boxes whose watcher is OFF.
|
// unconditionally: it exists for boxes whose watcher is OFF.
|
||||||
s.router.HandleFunc("/api/files/register", s.handleRegisterFile).Methods("POST")
|
s.router.HandleFunc("/api/files/register", s.handleRegisterFile).Methods("POST")
|
||||||
|
// File-backed roots (filePath+sizeByte+mtimeNano) with sizes, batched
|
||||||
|
// server-side, so clients answer "how many files, how big" without one
|
||||||
|
// read per hashId. ?summary=1 returns only {count, totalSize}.
|
||||||
|
s.router.HandleFunc("/api/files/tuples", s.handleGetFileTuples).Methods("GET")
|
||||||
|
|
||||||
// Public metadata-by-CID resolver. Reverse-index lookup → full document.
|
// Public metadata-by-CID resolver. Reverse-index lookup → full document.
|
||||||
// Auth-bypassed alongside /api/file/{cid} so meta-share peers can query
|
// Auth-bypassed alongside /api/file/{cid} so meta-share peers can query
|
||||||
|
|||||||
@@ -128,6 +128,12 @@
|
|||||||
"rank": 5,
|
"rank": 5,
|
||||||
"note": "codec 0x1009 + IDENTITY multihash 0x00, digest = the verbatim UTF-8 URL. WIRE-IDENTICAL to url (0x1006) apart from the codec, and that difference is the entire contract: 0x1006 means 'fetch these bytes once and seed them', 0x1009 means 'open this in a browser, there are no bytes for us'. A decoder that matched on SHAPE rather than on the codec slot would see an identity multihash wrapping an http(s) URL, take the 0x1006 path, fetch a watch/Bandcamp page, chunk it, seed the HTML and link that content CID onto the record AS THE FILE — the MSR1 poisoning shape, except permanent and replicated. Same tier/reasoning as the rest of the locator family."
|
"note": "codec 0x1009 + IDENTITY multihash 0x00, digest = the verbatim UTF-8 URL. WIRE-IDENTICAL to url (0x1006) apart from the codec, and that difference is the entire contract: 0x1006 means 'fetch these bytes once and seed them', 0x1009 means 'open this in a browser, there are no bytes for us'. A decoder that matched on SHAPE rather than on the codec slot would see an identity multihash wrapping an http(s) URL, take the 0x1006 path, fetch a watch/Bandcamp page, chunk it, seed the HTML and link that content CID onto the record AS THE FILE — the MSR1 poisoning shape, except permanent and replicated. Same tier/reasoning as the rest of the locator family."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "provider-file-locator",
|
||||||
|
"cid": "bagfcaaa2bvxxazloon2we5djorwgk43gnfwgkorxga3dcobtgq",
|
||||||
|
"rank": 5,
|
||||||
|
"note": "codec 0x100A + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id - here (\"opensubtitles\",\"file:7061834\"). The GENERIC fetchable locator: one codec for every provider whose bytes are fetched the same way; the provider is inside the digest. Framing byte-identical to card (0x1007), but the byte path RESOLVES it (via source's fetch tier) instead of 404ing. Same codec-slot rule as the rest of the family: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5: once the fetched bytes' sha2-256 is linked onto the record it wins the canonical election with no addressing change."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sha1",
|
"name": "sha1",
|
||||||
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
|
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ const (
|
|||||||
CodeYtVideo = 0x1008 // custom — identity-multihash CID wrapping a YouTube (kind, id)
|
CodeYtVideo = 0x1008 // custom — identity-multihash CID wrapping a YouTube (kind, id)
|
||||||
CodeExtPlay = 0x1009 // custom — identity-multihash CID wrapping a URL to OPEN, never fetch
|
CodeExtPlay = 0x1009 // custom — identity-multihash CID wrapping a URL to OPEN, never fetch
|
||||||
|
|
||||||
|
// CodeProviderFile is the FETCHABLE twin of CodeExtPlay: a provider-held
|
||||||
|
// file addressed as varint(len(source))‖source‖id — ("opensubtitles",
|
||||||
|
// "file:7061834"). Framing is byte-identical to CodeCard; only the byte
|
||||||
|
// path differs (it redeems the id through that source's feeder instead of
|
||||||
|
// 404ing). Same codec-slot rule and same tier: once the fetched bytes'
|
||||||
|
// sha2-256 is linked onto the record it wins with no addressing change.
|
||||||
|
CodeProviderFile = 0x100A // custom — identity-multihash CID wrapping a (source, id) provider file
|
||||||
|
|
||||||
// CodeNzbPosting is the INVERSE trap of the two above: its multihash is a
|
// CodeNzbPosting is the INVERSE trap of the two above: its multihash is a
|
||||||
// REAL sha2-256 digest (over the normalised, sorted article Message-ID
|
// REAL sha2-256 digest (over the normalised, sorted article Message-ID
|
||||||
// set of a Usenet posting we scanned ourselves — never a locator, no
|
// set of a Usenet posting we scanned ourselves — never a locator, no
|
||||||
@@ -183,7 +191,7 @@ func Rank(cidStr string) int {
|
|||||||
// Opaque locators. Codec-only: their multihash is identity (0x00), so
|
// Opaque locators. Codec-only: their multihash is identity (0x00), so
|
||||||
// there is nothing to match on the mh side.
|
// there is nothing to match on the mh side.
|
||||||
if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard ||
|
if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard ||
|
||||||
codec == CodeYtVideo || codec == CodeExtPlay {
|
codec == CodeYtVideo || codec == CodeExtPlay || codec == CodeProviderFile {
|
||||||
return RankLocator
|
return RankLocator
|
||||||
}
|
}
|
||||||
// nzb-posting: codec-only, checked BEFORE the mh switch below — its
|
// nzb-posting: codec-only, checked BEFORE the mh switch below — its
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestFieldIndex_SetMetadataFlatIndexesFields(t *testing.T) {
|
|||||||
"filePath": "/files/watch/a.mkv",
|
"filePath": "/files/watch/a.mkv",
|
||||||
"sizeByte": "123",
|
"sizeByte": "123",
|
||||||
"cids/bafkreiaaa": "true",
|
"cids/bafkreiaaa": "true",
|
||||||
"titles/jpn": "ナルト",
|
"titles/jpn/ナルト": "true",
|
||||||
"categories/TV/UHD": "true",
|
"categories/TV/UHD": "true",
|
||||||
"info/files/0/path": "a.mkv",
|
"info/files/0/path": "a.mkv",
|
||||||
"provenance": `{"source":"gateway"}`,
|
"provenance": `{"source":"gateway"}`,
|
||||||
@@ -380,7 +380,7 @@ func TestFieldIndex_WarmBuildsEveryMissingIndex(t *testing.T) {
|
|||||||
// written through the normal path (already indexed).
|
// written through the normal path (already indexed).
|
||||||
legacy := map[string]map[string]string{
|
legacy := map[string]map[string]string{
|
||||||
"old1": {"filePath": "/a.mkv", "cids/bafkreiaaa": "true"},
|
"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 root, fields := range legacy {
|
||||||
for f, v := range fields {
|
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"}) {
|
if got := fieldNames(t, c, "old1"); !reflect.DeepEqual(got, []string{"cids/bafkreiaaa", "filePath"}) {
|
||||||
t.Errorf("old1 index = %v", got)
|
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)
|
t.Errorf("old2 index = %v", got)
|
||||||
}
|
}
|
||||||
if got := fieldNames(t, c, "fresh"); !reflect.DeepEqual(got, []string{"title"}) {
|
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 {
|
if _, err := c.WarmFieldIndexes(); err != nil {
|
||||||
t.Fatalf("second WarmFieldIndexes: %v", err)
|
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)
|
t.Errorf("old2 index after second warm = %v", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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
|
// 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
|
// disagree about what is searchable — a drift there would make a record's
|
||||||
// matchability depend on which path happened to index it.
|
// matchability depend on which path happened to index it.
|
||||||
|
//
|
||||||
|
// Beyond the flat fields it folds in every `titles/<lang3>/<name>` 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 {
|
func buildHaystack(fields map[string]string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, f := range searchIndexFields {
|
for _, f := range searchIndexFields {
|
||||||
@@ -210,5 +216,30 @@ func buildHaystack(fields map[string]string) string {
|
|||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, name := range titleMemberNames(fields) {
|
||||||
|
sb.WriteString(strings.ToLower(name))
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// titleMemberNames returns the names of a record's `titles/<lang3>/<name>`
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ func TestDirty_IncrementalTuplesMatchTheFullRebuild(t *testing.T) {
|
|||||||
"title": "Parity Release",
|
"title": "Parity Release",
|
||||||
"fileName": "parity.s01e01.mkv",
|
"fileName": "parity.s01e01.mkv",
|
||||||
"fileType": "video",
|
"fileType": "video",
|
||||||
|
"titles/eng/3 Percent": "true",
|
||||||
|
"titles/fra/3 Pourcent": "true",
|
||||||
"categories/XXX": "true",
|
"categories/XXX": "true",
|
||||||
"categories/newznab/5070": "true",
|
"categories/newznab/5070": "true",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -209,3 +211,20 @@ func TestDirty_IncrementalTuplesMatchTheFullRebuild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A `titles/<lang3>/<name>` 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user