package api import ( "encoding/json" "net/http" "strconv" "github.com/gorilla/mux" ) // HashIDsResponse is the response for GET /api/metadata/hash-ids type HashIDsResponse struct { HashIds []string `json:"hashIds"` Count int `json:"count"` } // MetadataListItem represents a file in the metadata list type MetadataListItem struct { HashID string `json:"hashId"` Title string `json:"title,omitempty"` FileName string `json:"fileName,omitempty"` FilePath string `json:"filePath,omitempty"` Type string `json:"type,omitempty"` Year string `json:"year,omitempty"` } // MetadataListResponse is the response for GET /api/metadata/list type MetadataListResponse struct { Items []MetadataListItem `json:"items"` Count int `json:"count"` Total int `json:"total"` } // MetadataSearchRequest is the request body for POST /api/metadata/search type MetadataSearchRequest struct { Query string `json:"query,omitempty"` HashID string `json:"hashId,omitempty"` Property string `json:"property,omitempty"` PropertyValue string `json:"propertyValue,omitempty"` Limit int `json:"limit,omitempty"` // As is a viewer uid: the search sees that signer's own claims overlaid // (and their records as candidates). Also accepted as ?as=. As string `json:"as,omitempty"` // Filters is the structured form: AND across entries, OR within an // entry's Values. Narrowed in SQL through the field index alongside the // free-text Query, then verified per record (see serveIndexedSearch), so an // id-anchored lookup costs an index probe rather than a read per record. // // Property/PropertyValue is the single-filter legacy shape and folds into // this list (see normalizedFilters) with Exact=false, preserving its // substring semantics byte-for-byte. Filters []MetadataFilter `json:"filters,omitempty"` } // MetadataFilter is one structured constraint on a record. // // A record satisfies it when ANY of Values matches, and a search result must // satisfy EVERY filter (OR within, AND across). An entry with no usable value // constrains nothing — same rule the gateway's `genres_filter_matches` uses, so // a caller that lowers an empty filter doesn't accidentally empty the result. type MetadataFilter struct { // Property is the field name, WITHOUT a key-set suffix: `genres`, not // `genres/Action`. Both storage shapes are probed — see recordMatchesFilter. Property string `json:"property"` // Values are OR'd. Compared case-insensitively. Values []string `json:"values,omitempty"` // Exact selects CSV-exact comparison (the semantics meta-gateway's // `any_csv_value_matches` applies at its own Layer-B re-filter) over // substring containment. Callers doing identity lookups want this: with a // result limit, a substring match on `tmdbid=1405` also admits `21405` and // can crowd the real matches out of the page. Exact bool `json:"exact,omitempty"` } // MetadataSearchResult is a single search result type MetadataSearchResult struct { HashID string `json:"hashId"` Metadata map[string]string `json:"metadata"` } // MetadataSearchResponse is the response for POST /api/metadata/search type MetadataSearchResponse struct { Results []MetadataSearchResult `json:"results"` Count int `json:"count"` Total int `json:"total"` } // MetadataBatchUpdateRequest is the request body for POST /api/metadata/batch type MetadataBatchUpdateRequest struct { Updates []MetadataBatchItem `json:"updates"` } // MetadataBatchItem is a single item in a batch update type MetadataBatchItem struct { HashID string `json:"hashId"` Metadata map[string]string `json:"metadata,omitempty"` Properties map[string]string `json:"properties,omitempty"` } // MetadataBatchResult is a single result from a batch update type MetadataBatchResult struct { HashID string `json:"hashId"` Status string `json:"status"` Error string `json:"error,omitempty"` } // MetadataBatchResponse is the response for POST /api/metadata/batch type MetadataBatchResponse struct { Status string `json:"status"` Total int `json:"total"` Success int `json:"success"` Errors int `json:"errors"` Results []MetadataBatchResult `json:"results"` } // PropertyUpdateRequest is the request body for PUT /api/metadata/{hashId}/property type PropertyUpdateRequest struct { Property string `json:"property"` Value string `json:"value"` } // handleGetHashIds handles GET /api/metadata/hash-ids func (s *Server) handleGetHashIds(w http.ResponseWriter, r *http.Request) { if !s.storage.IsConnected() { writeError(w, http.StatusServiceUnavailable, "storage not connected") return } hashIDs, err := s.storage.GetAllHashIDs() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } response := HashIDsResponse{ HashIds: hashIDs, Count: len(hashIDs), } writeJSON(w, http.StatusOK, response) } // handleListMetadata handles GET /api/metadata/list func (s *Server) handleListMetadata(w http.ResponseWriter, r *http.Request) { if !s.storage.IsConnected() { writeError(w, http.StatusServiceUnavailable, "storage not connected") return } // Parse pagination parameters offsetStr := r.URL.Query().Get("offset") limitStr := r.URL.Query().Get("limit") offset := 0 if offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil { offset = o } } limit := 100 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } } // Get all hash IDs allHashIDs, err := s.storage.GetAllHashIDs() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } total := len(allHashIDs) // Apply pagination end := offset + limit if end > total { end = total } var items []MetadataListItem if offset < total { for _, hashID := range allHashIDs[offset:end] { metadata, err := s.storage.GetMetadataFlat(hashID) if err != nil || metadata == nil { continue } item := MetadataListItem{ HashID: hashID, Title: metadata["title"], FileName: metadata["fileName"], FilePath: metadata["filePath"], Type: metadata["type"], Year: metadata["year"], } items = append(items, item) } } response := MetadataListResponse{ Items: items, Count: len(items), Total: total, } writeJSON(w, http.StatusOK, response) } // handleGetMetadataByHashId handles GET /api/metadata/{hashId} func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) hashID := vars["hashId"] if hashID == "" { writeError(w, http.StatusBadRequest, "hash ID is required") return } if !s.storage.IsConnected() { writeError(w, http.StatusServiceUnavailable, "storage not connected") return } // Callers may hold a CID, not the root. hashID = s.storage.ResolveRoot(hashID) metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as")) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if metadata == nil { writeError(w, http.StatusNotFound, "metadata not found") return } response := MetadataResponse{ HashID: hashID, Metadata: metadata, } writeJSON(w, http.StatusOK, response) } // handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) hashID := vars["hashId"] property := r.URL.Query().Get("property") if hashID == "" { writeError(w, http.StatusBadRequest, "hash ID is required") return } if property == "" { writeError(w, http.StatusBadRequest, "property parameter is required") return } if !s.storage.IsConnected() { writeError(w, http.StatusServiceUnavailable, "storage not connected") return } value, err := s.storage.GetProperty(hashID, property) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if value == "" { writeError(w, http.StatusNotFound, "property not found") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "hashId": hashID, "property": property, "value": value, }) } // handleSearchMetadata handles POST /api/metadata/search func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) { if !s.storage.IsConnected() { writeError(w, http.StatusServiceUnavailable, "storage not connected") return } var req MetadataSearchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if req.Limit == 0 { req.Limit = 100 } if req.As == "" { req.As = r.URL.Query().Get("as") } // If hashId is provided, do exact match if req.HashID != "" { metadata, err := s.storage.GetMetadataFlat(req.HashID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if metadata != nil { writeJSON(w, http.StatusOK, MetadataSearchResponse{ Results: []MetadataSearchResult{{HashID: req.HashID, Metadata: metadata}}, Count: 1, Total: 1, }) return } writeJSON(w, http.StatusOK, MetadataSearchResponse{ Results: []MetadataSearchResult{}, Count: 0, Total: 0, }) return } // Everything else — free text, structured filters, both, or neither // (which returns the first `limit`) — is served from the store's derived // search indexes (search_index.go → storage.Search). // // There is deliberately no second path. The property branch used to fall // through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which // is what made an id-anchored lookup 3.63s over ~20k records (vs 10ms for // the free-text branch on the same corpus) and kept meta-core out of the // gateway's anchored search path entirely. The index already holds every // field of every record, so that scan was re-reading data it had in hand. s.serveIndexedSearch(w, req) } // handleClearMetadata handles POST /api/metadata/clear func (s *Server) handleClearMetadata(w http.ResponseWriter, r *http.Request) { if !s.storage.IsConnected() { writeError(w, http.StatusServiceUnavailable, "storage not connected") return } deletedCount, err := s.storage.ClearAllMetadata() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "message": "Metadata cleared", "deletedCount": deletedCount, }) }